|
71117
|
NULL
|
0
|
2026-05-22T11:26:52.441952+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449212441_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449212441_m2.jpg...
|
18PhostormVIewINavicarecodeFV faVsco.js?9 masterro 18PhostormVIewINavicarecodeFV faVsco.js?9 masterroledey(c) PlanhatActivityListener.pnpReindexForAccountListener.phpCo kelndexrorcontdetListener.onp© ReindexForGroupListener.phpCo kelndexrorLedoListener.onpNo kelnoexrorvpeonunityListener.ono© ReindexForUserListener.php© ResolveProvider.php© SendExportEmail.php© SetupIntegrationAction.php© UserPilotActivityListener.php›U Authenticationu Automaredkeporisu Calendars0 crmDealRisksGrouosMailboxm Nudaes> M UserPilot(C) CreateNudaeCreatedEvent.oho› M Ooportunitiesm Plavbooksc) Attachl avout nhol© ChangeSidekickSettings.php© CreatePlaybookCreatedEvent.phpv D Playlists>@ Activitiesv M Planhat© CreateActivityAddedToPlaylistEvent.p>@ UserPilotv @ Teams€ ImportGroupPlaylistSharesListener.php>@ UserPilot© CreateDefaultJobTitles.phpC) CreateDefaultLavouts.phoc) createTeamFolders.ohgCreate TwilioSubAccount.phpP CreateTwilioSubAccountOnUodate.ohpC)DeactivateTeamAction.onoCHubsootAooUninstallListener.oho©InstallinstantinviteCapableProviders.ohpC) RemoveSlackBot.ohvC)SvncintercomComoanv.ohoC) SvncPlanhatCombanv.oho@UnsubccriheTeam=ventsAction.nhnYc) UndateSalecforceAccount nhniM Transcrintionv Mllsers•PurgeLookupcache.pnposyncriannat.pnp© SyncToPlanhat.php©PlaybackController.phppnp api.phpC) CreateSmsSentEvent.phpC) PlanhatActivityListener.pho(C) AskAnythingPromptService.pho(C) AutomatedReportsRepositorv.php(C) AutomatedReportsCommand.phpphp api v2.phpC) RequestGenerateReportJob.pho(C) AutomatedReportResult.php©AutomatedReport.ohpuse Jiminny Models Lead"use Jiminny Models Nudge:use Jiminny Models Plavbookcategory.use Jiminny Models Plavuistiuse Jiminny Models Stage:use Jiminny Repositories \CoachingFeedbackRepository:use Jiminnv Renositories ElasticActivitvRenository:use Jiminnv Renositories TeamPenository:use Jiminny Rules\CrmReference;luse liminnv Rules Mul +idimencional ArnavMayCharRuile:use Jiminny\Services \ActivityService;use Jiminny\Services\Activity\Crm0wnerResolver;use Jiminny\Services\Crm\ProviderRegistry:Accept Rejectuse semanny oervaces rlaybackoervaceuse Jininhy services userserviceuse Jiminny\VO\Repository\OnDemandActivitySearch\Criteriause rsr Log Loggerinterraceuse Ramsey Uuid Uuid:use sentryfinal class ActivityController extends Controller implements CommentContextInterface// Number of minutes to look back on activities. i.e. a timeout on activity durationorivate const int LOOK BACK = 1801100 đpublic function -_construct(private ProviderRegistry SproviderRegistry,private ActivityService SactivityServiceResponse Sresponselprivate UserService SusenService,private ActivitySearch\Service \ActivitySearch SactivitySearch,private NudgeFactoryInterface SnudgeFactory,private ActivityCommentService SactivityCommentServiceprivate LoggerInterface Sloggerprivate readonly CoachingFeedbackRepository $coachingFeedbackRepository.private readonly TeamRepository $teamRepository,= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD](C) MatchCrmData.onp© UpdateCrmFieldDataJob.php# console SlAGiNGuse ILLuminate Contracts Queue Shoulduueue:final readonly class UodateSalesforce ccount imolements Shou doueueoublic functionorivate ProviderRegistry ScrmRegistry..private LoggerInterface $logger,D1..}public function handle(TeamCreated|TeamUpdated Sevent): void$teamCreator = $event->teamCreator;if (Sevent->team->account_id === null || $teamCreator === null) {ScrmService = Sthis->crmRegistry->oetSteamCreaton->aetteamO->aetCrmConfzouration@->oetProviderNamesteam = steamureator->oereamorSintearationAdmin = Ste•->isCrmRequiredO 2 SteamCreator : Steam->aetOwnerO:if (SteamCreator->isCrmRequired)scrmservice-›secusersceancreacorr} else {scrmservice->setuserreamcreator->oetleamo->qetuwnero.tryfScrmResolver = app( abstract: Crm0wnerResolver::class.'providerSlug' => Steam->qetCrmConfiqurationO->getProviderNameOcatch(Exception se)Sthis->loager->info('Attemot to resolve CRM service for •Sevent->team-›cetluldo.' tarled.".'excention' => se->cetcodeo.tny sSevent->team->account->aetCrmProvidenTd0_X Reiect Filetnaxa() + 5 of 6 files →100% 1528• Fri 22 May 14:26:52CascadeInvestigate Foreign KeSalestorce Token Fallb+0 ..• CreateActivityFromUploadService.php• CreateActivityFromUploadService.php+6 -2Now the medium orioritv itemsPoad UndateSalesforceAccount.png• UpdateSalesforceAccount.php• UpdateSalesforceAccount.php+16 -6Now the ActivityController opportunity sync:Exolored ActivitvController.oho and searched 2 aueries^ ActivitvController.nhnActivitvController.ohvl+7-76/6 tacke doneAll 6 nlaces have been undated to use Crmlwne rRecolver with fallback to other team members' tokens when theSummary of ChangesHigh Priority - Same root cause as Twilio1. MatchCrmData.php: 179-194 - Critical job for matching CRM data on dialer recordings2. HydrateCrmDataBvExternalCallIdJob.oho:60-76 - Hvdrates CRM data bv external call ID (Amazon Connect)3. UpdateCrmFieldDataJob.php:202-217 - Updates CRM field data on activities4. CreateActivitvFromUoloadService.oho:113-119 - Parses CRM records for uoloaded recordinasMedium Priority5. UpdateSalesforceAccount.php: 28-44 - Updates Salesforce account on team changes6. ActivityController.oho: 1177-1192 - Svncs opportunity on activity view.Pattern UsedCosh loastinn nau1. Creates a CrmOwnerResolver with the team and intended admin2. Calls prepareCrmServiceo which tries the admin first. then falls back to team members with active tokens orderecView allapp/Listeners/Teams/# UpdateSalesforceAccount.pho +16-5app/Jobs/Activity/Import/@ MatchCrmData.php +9 -4app/Component/Uploader/Services/ D CreateActivityFromUploadService.php +7 -2annllohe/Activitvllmnort/ MLIndatoCrmGioldData.lob.nhntg.ann/lobs/Activitv/Imnort/ M HvdrateCrmDataRvSyternalCallid.loh.ohn18gapo/Htto/Controllers/AP//[M ActivitvController.ohv +8* Reiect alliiAccent alliAsk anvthina (&4-L)<> Code Claude Qnus 1.7 MediumPo. 4 spac...
|
NULL
|
6684673001274701133
|
NULL
|
app_switch
|
ocr
|
NULL
|
18PhostormVIewINavicarecodeFV faVsco.js?9 masterro 18PhostormVIewINavicarecodeFV faVsco.js?9 masterroledey(c) PlanhatActivityListener.pnpReindexForAccountListener.phpCo kelndexrorcontdetListener.onp© ReindexForGroupListener.phpCo kelndexrorLedoListener.onpNo kelnoexrorvpeonunityListener.ono© ReindexForUserListener.php© ResolveProvider.php© SendExportEmail.php© SetupIntegrationAction.php© UserPilotActivityListener.php›U Authenticationu Automaredkeporisu Calendars0 crmDealRisksGrouosMailboxm Nudaes> M UserPilot(C) CreateNudaeCreatedEvent.oho› M Ooportunitiesm Plavbooksc) Attachl avout nhol© ChangeSidekickSettings.php© CreatePlaybookCreatedEvent.phpv D Playlists>@ Activitiesv M Planhat© CreateActivityAddedToPlaylistEvent.p>@ UserPilotv @ Teams€ ImportGroupPlaylistSharesListener.php>@ UserPilot© CreateDefaultJobTitles.phpC) CreateDefaultLavouts.phoc) createTeamFolders.ohgCreate TwilioSubAccount.phpP CreateTwilioSubAccountOnUodate.ohpC)DeactivateTeamAction.onoCHubsootAooUninstallListener.oho©InstallinstantinviteCapableProviders.ohpC) RemoveSlackBot.ohvC)SvncintercomComoanv.ohoC) SvncPlanhatCombanv.oho@UnsubccriheTeam=ventsAction.nhnYc) UndateSalecforceAccount nhniM Transcrintionv Mllsers•PurgeLookupcache.pnposyncriannat.pnp© SyncToPlanhat.php©PlaybackController.phppnp api.phpC) CreateSmsSentEvent.phpC) PlanhatActivityListener.pho(C) AskAnythingPromptService.pho(C) AutomatedReportsRepositorv.php(C) AutomatedReportsCommand.phpphp api v2.phpC) RequestGenerateReportJob.pho(C) AutomatedReportResult.php©AutomatedReport.ohpuse Jiminny Models Lead"use Jiminny Models Nudge:use Jiminny Models Plavbookcategory.use Jiminny Models Plavuistiuse Jiminny Models Stage:use Jiminny Repositories \CoachingFeedbackRepository:use Jiminnv Renositories ElasticActivitvRenository:use Jiminnv Renositories TeamPenository:use Jiminny Rules\CrmReference;luse liminnv Rules Mul +idimencional ArnavMayCharRuile:use Jiminny\Services \ActivityService;use Jiminny\Services\Activity\Crm0wnerResolver;use Jiminny\Services\Crm\ProviderRegistry:Accept Rejectuse semanny oervaces rlaybackoervaceuse Jininhy services userserviceuse Jiminny\VO\Repository\OnDemandActivitySearch\Criteriause rsr Log Loggerinterraceuse Ramsey Uuid Uuid:use sentryfinal class ActivityController extends Controller implements CommentContextInterface// Number of minutes to look back on activities. i.e. a timeout on activity durationorivate const int LOOK BACK = 1801100 đpublic function -_construct(private ProviderRegistry SproviderRegistry,private ActivityService SactivityServiceResponse Sresponselprivate UserService SusenService,private ActivitySearch\Service \ActivitySearch SactivitySearch,private NudgeFactoryInterface SnudgeFactory,private ActivityCommentService SactivityCommentServiceprivate LoggerInterface Sloggerprivate readonly CoachingFeedbackRepository $coachingFeedbackRepository.private readonly TeamRepository $teamRepository,= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD](C) MatchCrmData.onp© UpdateCrmFieldDataJob.php# console SlAGiNGuse ILLuminate Contracts Queue Shoulduueue:final readonly class UodateSalesforce ccount imolements Shou doueueoublic functionorivate ProviderRegistry ScrmRegistry..private LoggerInterface $logger,D1..}public function handle(TeamCreated|TeamUpdated Sevent): void$teamCreator = $event->teamCreator;if (Sevent->team->account_id === null || $teamCreator === null) {ScrmService = Sthis->crmRegistry->oetSteamCreaton->aetteamO->aetCrmConfzouration@->oetProviderNamesteam = steamureator->oereamorSintearationAdmin = Ste•->isCrmRequiredO 2 SteamCreator : Steam->aetOwnerO:if (SteamCreator->isCrmRequired)scrmservice-›secusersceancreacorr} else {scrmservice->setuserreamcreator->oetleamo->qetuwnero.tryfScrmResolver = app( abstract: Crm0wnerResolver::class.'providerSlug' => Steam->qetCrmConfiqurationO->getProviderNameOcatch(Exception se)Sthis->loager->info('Attemot to resolve CRM service for •Sevent->team-›cetluldo.' tarled.".'excention' => se->cetcodeo.tny sSevent->team->account->aetCrmProvidenTd0_X Reiect Filetnaxa() + 5 of 6 files →100% 1528• Fri 22 May 14:26:52CascadeInvestigate Foreign KeSalestorce Token Fallb+0 ..• CreateActivityFromUploadService.php• CreateActivityFromUploadService.php+6 -2Now the medium orioritv itemsPoad UndateSalesforceAccount.png• UpdateSalesforceAccount.php• UpdateSalesforceAccount.php+16 -6Now the ActivityController opportunity sync:Exolored ActivitvController.oho and searched 2 aueries^ ActivitvController.nhnActivitvController.ohvl+7-76/6 tacke doneAll 6 nlaces have been undated to use Crmlwne rRecolver with fallback to other team members' tokens when theSummary of ChangesHigh Priority - Same root cause as Twilio1. MatchCrmData.php: 179-194 - Critical job for matching CRM data on dialer recordings2. HydrateCrmDataBvExternalCallIdJob.oho:60-76 - Hvdrates CRM data bv external call ID (Amazon Connect)3. UpdateCrmFieldDataJob.php:202-217 - Updates CRM field data on activities4. CreateActivitvFromUoloadService.oho:113-119 - Parses CRM records for uoloaded recordinasMedium Priority5. UpdateSalesforceAccount.php: 28-44 - Updates Salesforce account on team changes6. ActivityController.oho: 1177-1192 - Svncs opportunity on activity view.Pattern UsedCosh loastinn nau1. Creates a CrmOwnerResolver with the team and intended admin2. Calls prepareCrmServiceo which tries the admin first. then falls back to team members with active tokens orderecView allapp/Listeners/Teams/# UpdateSalesforceAccount.pho +16-5app/Jobs/Activity/Import/@ MatchCrmData.php +9 -4app/Component/Uploader/Services/ D CreateActivityFromUploadService.php +7 -2annllohe/Activitvllmnort/ MLIndatoCrmGioldData.lob.nhntg.ann/lobs/Activitv/Imnort/ M HvdrateCrmDataRvSyternalCallid.loh.ohn18gapo/Htto/Controllers/AP//[M ActivitvController.ohv +8* Reiect alliiAccent alliAsk anvthina (&4-L)<> Code Claude Qnus 1.7 MediumPo. 4 spac...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71115
|
NULL
|
0
|
2026-05-22T11:26:49.848699+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449209848_m1.jpg...
|
Firefox
|
[SRD-6848] Sidekick SMS issue - Jira — Work
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848...
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449209848_m1.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
[SRD-6848] Sidekick SMS issue - Jira
[SRD-6848] Sidekick SMS issue - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"[SRD-6848] Sidekick SMS issue - 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":"[SRD-6848] Sidekick SMS issue - 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":"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-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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":false},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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":false},{"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":"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}]...
|
3288272868028513905
|
-3810520677072833851
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
[SRD-6848] Sidekick SMS issue - Jira
[SRD-6848] Sidekick SMS issue - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71116
|
NULL
|
0
|
2026-05-22T11:26:49.814592+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449209814_m2.jpg...
|
Firefox
|
[SRD-6848] Sidekick SMS issue - Jira — Work
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449209814_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
[SRD-6848] Sidekick SMS issue - Jira
[SRD-6848] Sidekick SMS issue - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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
Notifications
Notifications
Help
Help
Settings
Settings...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"[SRD-6848] Sidekick SMS issue - Jira","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":true},{"role":"AXStaticText","text":"[SRD-6848] Sidekick SMS issue - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.06632314,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"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.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":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"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.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":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"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.35155627,"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":false},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.37071028,"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 deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.38188347,"width":0.14245346,"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.40343177,"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-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.41460496,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.43774942,"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":"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":"Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.027759308,"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"}]...
|
-5880572259044018304
|
-8426960983174884732
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
[SRD-6848] Sidekick SMS issue - Jira
[SRD-6848] Sidekick SMS issue - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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
Notifications
Notifications
Help
Help
Settings
Settings...
|
71114
|
NULL
|
NULL
|
NULL
|
|
71114
|
NULL
|
0
|
2026-05-22T11:26:43.524046+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449203524_m2.jpg...
|
Firefox
|
Service-Desk - Queues - Platform team - Service sp Service-Desk - Queues - Platform team - Service space - Jira — Work...
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449203524_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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
Notifications
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)
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
Summary
Summary
More actions for summary
More actions for summary
Queues
Queues
Create
Create
More for queues
More for queues
Team Priority
Team Priority
All open tickets
All open tickets
Star All open tickets
-
Unassigned tickets
Unassigned tickets
Star Unassigned tickets
-
Support team Queue
Support team Queue
Star Support team Queue
-
Raised by me
Raised by me
Star Raised by me
-
Assigned to me
Assigned to me
Star Assigned to me
-
Service requests
Service requests
Star Service requests
-
Platform team
Platform team
Star Platform team
-
Processing team
Processing team
Star Processing team
-
Site reliability team
Site reliability team
Star Site reliability team
-
New features requests
New features requests
Star New features requests
-
InfoSec issues
InfoSec issues
Star InfoSec issues
-
Ready for Customer
Ready for Customer
Star Ready for Customer
-
Resolved tickets
Resolved tickets
Star Resolved tickets
-
View all queues
View all queues
Service requests
Service requests
Create
Create
More for service requests
More for service requests
Incidents
Incidents
Create
Create
More for incidents
More for incidents
Reports
Reports
More actions for reports
More actions for reports
Operations
Operations
More actions for operations
More actions for operations
Knowledge Base
Knowledge Base
More actions for knowledge base
More actions for knowledge base
Customers
Customers
More actions for customers
More actions for customers
Channels
Channels
Email logs
Email logs
More actions for customer notification logs
More actions for customer notification logs
Developer escalations
Developer escalations
More actions for developer escalations
More actions for developer escalations
Slack integration
Slack integration
More actions for Slack integration
More actions for Slack integration
Reporting Center
Reporting Center
More actions for Reporting Center
More actions for Reporting Center
Add shortcut
Add shortcut
More actions for developer escalations
More actions for developer escalations
Archived work items
Archived work items
More actions for archived work items
More actions for archived work items
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...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Service-Desk - Queues - Platform team - Service space - Jira","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":true},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.10721409,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"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.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":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"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.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":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"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.35155627,"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":false},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.37071028,"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 deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.38188347,"width":0.14245346,"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.40343177,"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-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.41460496,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.43774942,"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":"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":"Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.027759308,"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":false},{"role":"AXMenuButton","text":"Create board","depth":18,"bounds":{"left":0.15508644,"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.16240026,"top":0.25618514,"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 Jiminny (New)","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service-Desk","depth":17,"bounds":{"left":0.08759973,"top":0.27853152,"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":true},{"role":"AXStaticText","text":"Service-Desk","depth":20,"bounds":{"left":0.09823803,"top":0.28451717,"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.14577793,"top":0.28172386,"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 Service-Desk","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Summary","depth":21,"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":"Summary","depth":24,"bounds":{"left":0.1022274,"top":0.31005585,"width":0.021276595,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for summary","depth":22,"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":"More actions for summary","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Queues","depth":21,"bounds":{"left":0.09158909,"top":0.32960895,"width":0.06349734,"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":"Queues","depth":24,"bounds":{"left":0.1022274,"top":0.33559456,"width":0.017121011,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create","depth":22,"bounds":{"left":0.13646941,"top":0.33280128,"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","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More for queues","depth":22,"bounds":{"left":0.14577793,"top":0.33280128,"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 for queues","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Team Priority","depth":23,"bounds":{"left":0.095578454,"top":0.35514766,"width":0.059507977,"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":"Team Priority","depth":26,"bounds":{"left":0.10621676,"top":0.36113328,"width":0.029587766,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"All open tickets","depth":25,"bounds":{"left":0.099567816,"top":0.38068634,"width":0.055518616,"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":"All open tickets","depth":28,"bounds":{"left":0.11020612,"top":0.386672,"width":0.034075797,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star All open tickets","depth":26,"bounds":{"left":0.14577793,"top":0.38387868,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.38826814,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Unassigned tickets","depth":25,"bounds":{"left":0.099567816,"top":0.40622506,"width":0.055518616,"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":"Unassigned tickets","depth":28,"bounds":{"left":0.11020612,"top":0.4122107,"width":0.03307846,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Unassigned tickets","depth":26,"bounds":{"left":0.14577793,"top":0.4094174,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.41380686,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Support team Queue","depth":25,"bounds":{"left":0.099567816,"top":0.43176377,"width":0.055518616,"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":"Support team Queue","depth":28,"bounds":{"left":0.11020612,"top":0.43774942,"width":0.03025266,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Support team Queue","depth":26,"bounds":{"left":0.14577793,"top":0.4349561,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.43934557,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Raised by me","depth":25,"bounds":{"left":0.099567816,"top":0.45730248,"width":0.055518616,"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":"Raised by me","depth":28,"bounds":{"left":0.11020612,"top":0.4632881,"width":0.029753989,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Raised by me","depth":26,"bounds":{"left":0.14577793,"top":0.46049482,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.46488428,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Assigned to me","depth":25,"bounds":{"left":0.099567816,"top":0.4828412,"width":0.055518616,"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":"Assigned to me","depth":28,"bounds":{"left":0.11020612,"top":0.4888268,"width":0.03174867,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Assigned to me","depth":26,"bounds":{"left":0.14577793,"top":0.48603353,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.490423,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Service requests","depth":25,"bounds":{"left":0.099567816,"top":0.5083799,"width":0.055518616,"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":"Service requests","depth":28,"bounds":{"left":0.11020612,"top":0.5143655,"width":0.033410903,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Service requests","depth":26,"bounds":{"left":0.14577793,"top":0.51157224,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.5159617,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Platform team","depth":25,"bounds":{"left":0.099567816,"top":0.5339186,"width":0.055518616,"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":28,"bounds":{"left":0.11020612,"top":0.53990424,"width":0.031083776,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Platform team","depth":26,"bounds":{"left":0.14577793,"top":0.5371109,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.5415004,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Processing team","depth":25,"bounds":{"left":0.099567816,"top":0.5594573,"width":0.055518616,"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":28,"bounds":{"left":0.11020612,"top":0.5654429,"width":0.03307846,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Processing team","depth":26,"bounds":{"left":0.14577793,"top":0.56264967,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.56703913,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Site reliability team","depth":25,"bounds":{"left":0.099567816,"top":0.584996,"width":0.055518616,"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":"Site reliability team","depth":28,"bounds":{"left":0.11020612,"top":0.59098166,"width":0.03274601,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Site reliability team","depth":26,"bounds":{"left":0.14577793,"top":0.58818835,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.5925778,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"New features requests","depth":25,"bounds":{"left":0.099567816,"top":0.6105347,"width":0.055518616,"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":"New features requests","depth":28,"bounds":{"left":0.11020612,"top":0.61652035,"width":0.032579787,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star New features requests","depth":26,"bounds":{"left":0.14577793,"top":0.61372703,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.6181165,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"InfoSec issues","depth":25,"bounds":{"left":0.099567816,"top":0.6360734,"width":0.055518616,"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":"InfoSec issues","depth":28,"bounds":{"left":0.11020612,"top":0.6420591,"width":0.03324468,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star InfoSec issues","depth":26,"bounds":{"left":0.14577793,"top":0.6392658,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.64365524,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Ready for Customer","depth":25,"bounds":{"left":0.099567816,"top":0.66161215,"width":0.055518616,"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":"Ready for Customer","depth":28,"bounds":{"left":0.11020612,"top":0.6675978,"width":0.032912236,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Ready for Customer","depth":26,"bounds":{"left":0.14577793,"top":0.66480446,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.6691939,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Resolved tickets","depth":25,"bounds":{"left":0.099567816,"top":0.68715084,"width":0.055518616,"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":"Resolved tickets","depth":28,"bounds":{"left":0.11020612,"top":0.69313645,"width":0.033909574,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Resolved tickets","depth":26,"bounds":{"left":0.14577793,"top":0.6903432,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.69473267,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"View all queues","depth":24,"bounds":{"left":0.095578454,"top":0.7126895,"width":0.059507977,"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":"View all queues","depth":27,"bounds":{"left":0.10621676,"top":0.7186752,"width":0.034906916,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service requests","depth":21,"bounds":{"left":0.09158909,"top":0.73822826,"width":0.06349734,"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 requests","depth":24,"bounds":{"left":0.1022274,"top":0.7442139,"width":0.03756649,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create","depth":22,"bounds":{"left":0.15309176,"top":0.74142057,"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":"Create","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More for service requests","depth":22,"bounds":{"left":0.15442154,"top":0.74142057,"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 for service requests","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Incidents","depth":22,"bounds":{"left":0.09158909,"top":0.76376694,"width":0.06349734,"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":"Incidents","depth":25,"bounds":{"left":0.1022274,"top":0.7697526,"width":0.021276595,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create","depth":23,"bounds":{"left":0.15309176,"top":0.7669593,"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":"Create","depth":25,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More for incidents","depth":23,"bounds":{"left":0.15442154,"top":0.7669593,"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 for incidents","depth":25,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Reports","depth":19,"bounds":{"left":0.09158909,"top":0.7893057,"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":"Reports","depth":22,"bounds":{"left":0.1022274,"top":0.7952913,"width":0.017287234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for reports","depth":20,"bounds":{"left":0.15309176,"top":0.792498,"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 reports","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Operations","depth":19,"bounds":{"left":0.09158909,"top":0.81484437,"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":"Operations","depth":22,"bounds":{"left":0.1022274,"top":0.82083,"width":0.02443484,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for operations","depth":20,"bounds":{"left":0.15309176,"top":0.81803674,"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":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Knowledge Base","depth":19,"bounds":{"left":0.09158909,"top":0.84038305,"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":"Knowledge Base","depth":22,"bounds":{"left":0.1022274,"top":0.84636873,"width":0.03723404,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for knowledge base","depth":20,"bounds":{"left":0.15309176,"top":0.8435754,"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 knowledge base","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Customers","depth":19,"bounds":{"left":0.09158909,"top":0.8659218,"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":"Customers","depth":22,"bounds":{"left":0.1022274,"top":0.8719074,"width":0.024268618,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for customers","depth":20,"bounds":{"left":0.15309176,"top":0.8691141,"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 customers","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Channels","depth":19,"bounds":{"left":0.09158909,"top":0.8914605,"width":0.06349734,"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":"Channels","depth":22,"bounds":{"left":0.1022274,"top":0.89744616,"width":0.020944148,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Email logs","depth":19,"bounds":{"left":0.09158909,"top":0.9169992,"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":"Email logs","depth":22,"bounds":{"left":0.1022274,"top":0.92298484,"width":0.022606382,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for customer notification logs","depth":20,"bounds":{"left":0.15309176,"top":0.9201915,"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 customer notification logs","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Developer escalations","depth":19,"bounds":{"left":0.09158909,"top":0.9425379,"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":"Developer escalations","depth":22,"bounds":{"left":0.1022274,"top":0.9485235,"width":0.04920213,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for developer escalations","depth":20,"bounds":{"left":0.15309176,"top":0.94573027,"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 developer escalations","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Slack integration","depth":19,"bounds":{"left":0.09158909,"top":0.9680766,"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":"Slack integration","depth":22,"bounds":{"left":0.1022274,"top":0.97406226,"width":0.03723404,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Slack integration","depth":20,"bounds":{"left":0.15309176,"top":0.97126895,"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 Slack integration","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Reporting Center","depth":19,"bounds":{"left":0.09158909,"top":0.9936153,"width":0.06349734,"height":0.0063846707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Reporting Center","depth":22,"bounds":{"left":0.1022274,"top":0.99960095,"width":0.037898935,"height":0.0003990531},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Reporting Center","depth":20,"bounds":{"left":0.15309176,"top":0.99680763,"width":0.0039893617,"height":0.0031923652},"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 Reporting Center","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add shortcut","depth":19,"bounds":{"left":0.09158909,"top":1.0,"width":0.06349734,"height":-0.019154072},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Add shortcut","depth":22,"bounds":{"left":0.1022274,"top":1.0,"width":0.028922873,"height":-0.02513969},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for developer escalations","depth":20,"bounds":{"left":0.15309176,"top":1.0,"width":0.0039893617,"height":-0.022346377},"on_screen":false,"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 developer escalations","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Archived work items","depth":19,"bounds":{"left":0.09158909,"top":1.0,"width":0.06349734,"height":-0.044692755},"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Archived work items","depth":22,"bounds":{"left":0.1022274,"top":1.0,"width":0.045545213,"height":-0.050678372},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for archived work items","depth":20,"bounds":{"left":0.15309176,"top":1.0,"width":0.0039893617,"height":-0.04788506},"on_screen":false,"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 archived work items","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"More spaces","depth":17,"bounds":{"left":0.08759973,"top":1.0,"width":0.0674867,"height":-0.07023144},"on_screen":false,"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":1.0,"width":0.028756648,"height":-0.076217055},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Filters","depth":12,"bounds":{"left":0.08361037,"top":1.0,"width":0.071476065,"height":-0.09577012},"on_screen":false,"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":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Filters","depth":13,"bounds":{"left":0.15309176,"top":1.0,"width":0.0039893617,"height":-0.098962545},"on_screen":false,"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":false,"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":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create dashboard","depth":13,"on_screen":false,"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":false,"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":false,"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":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Operations","depth":13,"on_screen":false,"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":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Confluence","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Teams , (opens new window)","depth":13,"on_screen":false,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Teams","depth":17,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"open menu","depth":14,"on_screen":false,"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":false,"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":false,"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"}]...
|
-6903564820667902665
|
221650195746280676
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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
Notifications
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)
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
Summary
Summary
More actions for summary
More actions for summary
Queues
Queues
Create
Create
More for queues
More for queues
Team Priority
Team Priority
All open tickets
All open tickets
Star All open tickets
-
Unassigned tickets
Unassigned tickets
Star Unassigned tickets
-
Support team Queue
Support team Queue
Star Support team Queue
-
Raised by me
Raised by me
Star Raised by me
-
Assigned to me
Assigned to me
Star Assigned to me
-
Service requests
Service requests
Star Service requests
-
Platform team
Platform team
Star Platform team
-
Processing team
Processing team
Star Processing team
-
Site reliability team
Site reliability team
Star Site reliability team
-
New features requests
New features requests
Star New features requests
-
InfoSec issues
InfoSec issues
Star InfoSec issues
-
Ready for Customer
Ready for Customer
Star Ready for Customer
-
Resolved tickets
Resolved tickets
Star Resolved tickets
-
View all queues
View all queues
Service requests
Service requests
Create
Create
More for service requests
More for service requests
Incidents
Incidents
Create
Create
More for incidents
More for incidents
Reports
Reports
More actions for reports
More actions for reports
Operations
Operations
More actions for operations
More actions for operations
Knowledge Base
Knowledge Base
More actions for knowledge base
More actions for knowledge base
Customers
Customers
More actions for customers
More actions for customers
Channels
Channels
Email logs
Email logs
More actions for customer notification logs
More actions for customer notification logs
Developer escalations
Developer escalations
More actions for developer escalations
More actions for developer escalations
Slack integration
Slack integration
More actions for Slack integration
More actions for Slack integration
Reporting Center
Reporting Center
More actions for Reporting Center
More actions for Reporting Center
Add shortcut
Add shortcut
More actions for developer escalations
More actions for developer escalations
Archived work items
Archived work items
More actions for archived work items
More actions for archived work items
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...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71113
|
NULL
|
0
|
2026-05-22T11:26:41.316229+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449201316_m2.jpg...
|
Firefox
|
Service-Desk - Queues - Platform team - Service sp Service-Desk - Queues - Platform team - Service space - Jira — Work...
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449201316_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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
Notifications
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)
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
Summary
Summary
More actions for summary
More actions for summary
Queues
Queues
Create
Create
More for queues
More for queues
Team Priority
Team Priority
All open tickets
All open tickets
Star All open tickets
-
Unassigned tickets
Unassigned tickets
Star Unassigned tickets
-
Support team Queue
Support team Queue
Star Support team Queue
-
Raised by me
Raised by me
Star Raised by me
-
Assigned to me
Assigned to me
Star Assigned to me
-
Service requests
Service requests
Star Service requests
-
Platform team
Platform team
Star Platform team
-
Processing team
Processing team
Star Processing team
-
Site reliability team...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Service-Desk - Queues - Platform team - Service space - Jira","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":true},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.10721409,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"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.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":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"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.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":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"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.35155627,"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":false},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.37071028,"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 deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.38188347,"width":0.14245346,"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.40343177,"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-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.41460496,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.43774942,"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":"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":"Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.027759308,"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":false},{"role":"AXMenuButton","text":"Create board","depth":18,"bounds":{"left":0.15508644,"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.16240026,"top":0.25618514,"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 Jiminny (New)","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service-Desk","depth":17,"bounds":{"left":0.08759973,"top":0.27853152,"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":true},{"role":"AXStaticText","text":"Service-Desk","depth":20,"bounds":{"left":0.09823803,"top":0.28451717,"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.14577793,"top":0.28172386,"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 Service-Desk","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Summary","depth":21,"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":"Summary","depth":24,"bounds":{"left":0.1022274,"top":0.31005585,"width":0.021276595,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for summary","depth":22,"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":"More actions for summary","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Queues","depth":21,"bounds":{"left":0.09158909,"top":0.32960895,"width":0.06349734,"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":"Queues","depth":24,"bounds":{"left":0.1022274,"top":0.33559456,"width":0.017121011,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create","depth":22,"bounds":{"left":0.13646941,"top":0.33280128,"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","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More for queues","depth":22,"bounds":{"left":0.14577793,"top":0.33280128,"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 for queues","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Team Priority","depth":23,"bounds":{"left":0.095578454,"top":0.35514766,"width":0.059507977,"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":"Team Priority","depth":26,"bounds":{"left":0.10621676,"top":0.36113328,"width":0.029587766,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"All open tickets","depth":25,"bounds":{"left":0.099567816,"top":0.38068634,"width":0.055518616,"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":"All open tickets","depth":28,"bounds":{"left":0.11020612,"top":0.386672,"width":0.034075797,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star All open tickets","depth":26,"bounds":{"left":0.14577793,"top":0.38387868,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.38826814,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Unassigned tickets","depth":25,"bounds":{"left":0.099567816,"top":0.40622506,"width":0.055518616,"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":"Unassigned tickets","depth":28,"bounds":{"left":0.11020612,"top":0.4122107,"width":0.03307846,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Unassigned tickets","depth":26,"bounds":{"left":0.14577793,"top":0.4094174,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.41380686,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Support team Queue","depth":25,"bounds":{"left":0.099567816,"top":0.43176377,"width":0.055518616,"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":"Support team Queue","depth":28,"bounds":{"left":0.11020612,"top":0.43774942,"width":0.03025266,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Support team Queue","depth":26,"bounds":{"left":0.14577793,"top":0.4349561,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.43934557,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Raised by me","depth":25,"bounds":{"left":0.099567816,"top":0.45730248,"width":0.055518616,"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":"Raised by me","depth":28,"bounds":{"left":0.11020612,"top":0.4632881,"width":0.029753989,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Raised by me","depth":26,"bounds":{"left":0.14577793,"top":0.46049482,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.46488428,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Assigned to me","depth":25,"bounds":{"left":0.099567816,"top":0.4828412,"width":0.055518616,"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":"Assigned to me","depth":28,"bounds":{"left":0.11020612,"top":0.4888268,"width":0.03174867,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Assigned to me","depth":26,"bounds":{"left":0.14577793,"top":0.48603353,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.490423,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Service requests","depth":25,"bounds":{"left":0.099567816,"top":0.5083799,"width":0.055518616,"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":"Service requests","depth":28,"bounds":{"left":0.11020612,"top":0.5143655,"width":0.033410903,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Service requests","depth":26,"bounds":{"left":0.14577793,"top":0.51157224,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.5159617,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Platform team","depth":25,"bounds":{"left":0.099567816,"top":0.5339186,"width":0.055518616,"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":28,"bounds":{"left":0.11020612,"top":0.53990424,"width":0.031083776,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Platform team","depth":26,"bounds":{"left":0.14577793,"top":0.5371109,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.5415004,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Processing team","depth":25,"bounds":{"left":0.099567816,"top":0.5594573,"width":0.055518616,"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":28,"bounds":{"left":0.11020612,"top":0.5654429,"width":0.03307846,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Processing team","depth":26,"bounds":{"left":0.14577793,"top":0.56264967,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.56703913,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Site reliability team","depth":25,"bounds":{"left":0.099567816,"top":0.584996,"width":0.055518616,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-4884893903090399170
|
-4352896577404644220
|
click
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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
Notifications
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)
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
Summary
Summary
More actions for summary
More actions for summary
Queues
Queues
Create
Create
More for queues
More for queues
Team Priority
Team Priority
All open tickets
All open tickets
Star All open tickets
-
Unassigned tickets
Unassigned tickets
Star Unassigned tickets
-
Support team Queue
Support team Queue
Star Support team Queue
-
Raised by me
Raised by me
Star Raised by me
-
Assigned to me
Assigned to me
Star Assigned to me
-
Service requests
Service requests
Star Service requests
-
Platform team
Platform team
Star Platform team
-
Processing team
Processing team
Star Processing team
-
Site reliability team...
|
71112
|
NULL
|
NULL
|
NULL
|
|
71112
|
NULL
|
0
|
2026-05-22T11:26:40.313538+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449200313_m2.jpg...
|
Firefox
|
Service-Desk - Queues - Platform team - Service sp Service-Desk - Queues - Platform team - Service space - Jira — Work...
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449200313_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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
Notifications
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)
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
Summary
Summary
More actions for summary
More actions for summary
Queues
Queues
Create
Create
More for queues
More for queues
Team Priority
Team Priority
All open tickets
All open tickets
Star All open tickets
-
Unassigned tickets
Unassigned tickets
Star Unassigned tickets
-
Support team Queue
Support team Queue
Star Support team Queue
-
Raised by me
Raised by me
Star Raised by me
-
Assigned to me
Assigned to me
Star Assigned to me
-
Service requests
Service requests
Star Service requests
-
Platform team
Platform team
Star Platform team
-
Processing team
Processing team
Star Processing team
-
Site reliability team
Site reliability team
Star Site reliability team
-
New features requests
New features requests
Star New features requests
-
InfoSec issues
InfoSec issues
Star InfoSec issues
-
Ready for Customer
Ready for Customer
Star Ready for Customer
-
Resolved tickets
Resolved tickets
Star Resolved tickets
-
View all queues
View all queues
Service requests
Service requests
Create
Create
More for service requests
More for service requests
Incidents
Incidents
Create
Create
More for incidents
More for incidents
Reports
Reports
More actions for reports
More actions for reports
Operations
Operations
More actions for operations
More actions for operations
Knowledge Base...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Service-Desk - Queues - Platform team - Service space - Jira","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":true},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.10721409,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"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.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":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"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.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":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"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.35155627,"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":false},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.37071028,"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 deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.38188347,"width":0.14245346,"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.40343177,"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-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.41460496,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.43774942,"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":"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":"Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.027759308,"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":false},{"role":"AXMenuButton","text":"Create board","depth":18,"bounds":{"left":0.15508644,"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.16240026,"top":0.25618514,"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 Jiminny (New)","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service-Desk","depth":17,"bounds":{"left":0.08759973,"top":0.27853152,"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":true},{"role":"AXStaticText","text":"Service-Desk","depth":20,"bounds":{"left":0.09823803,"top":0.28451717,"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.14577793,"top":0.28172386,"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 Service-Desk","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Summary","depth":21,"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":"Summary","depth":24,"bounds":{"left":0.1022274,"top":0.31005585,"width":0.021276595,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for summary","depth":22,"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":"More actions for summary","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Queues","depth":21,"bounds":{"left":0.09158909,"top":0.32960895,"width":0.06349734,"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":"Queues","depth":24,"bounds":{"left":0.1022274,"top":0.33559456,"width":0.017121011,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create","depth":22,"bounds":{"left":0.13646941,"top":0.33280128,"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","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More for queues","depth":22,"bounds":{"left":0.14577793,"top":0.33280128,"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 for queues","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Team Priority","depth":23,"bounds":{"left":0.095578454,"top":0.35514766,"width":0.059507977,"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":"Team Priority","depth":26,"bounds":{"left":0.10621676,"top":0.36113328,"width":0.029587766,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"All open tickets","depth":25,"bounds":{"left":0.099567816,"top":0.38068634,"width":0.055518616,"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":"All open tickets","depth":28,"bounds":{"left":0.11020612,"top":0.386672,"width":0.034075797,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star All open tickets","depth":26,"bounds":{"left":0.14577793,"top":0.38387868,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.38826814,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Unassigned tickets","depth":25,"bounds":{"left":0.099567816,"top":0.40622506,"width":0.055518616,"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":"Unassigned tickets","depth":28,"bounds":{"left":0.11020612,"top":0.4122107,"width":0.03307846,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Unassigned tickets","depth":26,"bounds":{"left":0.14577793,"top":0.4094174,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.41380686,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Support team Queue","depth":25,"bounds":{"left":0.099567816,"top":0.43176377,"width":0.055518616,"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":"Support team Queue","depth":28,"bounds":{"left":0.11020612,"top":0.43774942,"width":0.03025266,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Support team Queue","depth":26,"bounds":{"left":0.14577793,"top":0.4349561,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.43934557,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Raised by me","depth":25,"bounds":{"left":0.099567816,"top":0.45730248,"width":0.055518616,"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":"Raised by me","depth":28,"bounds":{"left":0.11020612,"top":0.4632881,"width":0.029753989,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Raised by me","depth":26,"bounds":{"left":0.14577793,"top":0.46049482,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.46488428,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Assigned to me","depth":25,"bounds":{"left":0.099567816,"top":0.4828412,"width":0.055518616,"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":"Assigned to me","depth":28,"bounds":{"left":0.11020612,"top":0.4888268,"width":0.03174867,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Assigned to me","depth":26,"bounds":{"left":0.14577793,"top":0.48603353,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.490423,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Service requests","depth":25,"bounds":{"left":0.099567816,"top":0.5083799,"width":0.055518616,"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":"Service requests","depth":28,"bounds":{"left":0.11020612,"top":0.5143655,"width":0.033410903,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Service requests","depth":26,"bounds":{"left":0.14577793,"top":0.51157224,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.5159617,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Platform team","depth":25,"bounds":{"left":0.099567816,"top":0.5339186,"width":0.055518616,"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":28,"bounds":{"left":0.11020612,"top":0.53990424,"width":0.031083776,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Platform team","depth":26,"bounds":{"left":0.14577793,"top":0.5371109,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.5415004,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Processing team","depth":25,"bounds":{"left":0.099567816,"top":0.5594573,"width":0.055518616,"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":28,"bounds":{"left":0.11020612,"top":0.5654429,"width":0.03307846,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Processing team","depth":26,"bounds":{"left":0.14577793,"top":0.56264967,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.56703913,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Site reliability team","depth":25,"bounds":{"left":0.099567816,"top":0.584996,"width":0.055518616,"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":"Site reliability team","depth":28,"bounds":{"left":0.11020612,"top":0.59098166,"width":0.03274601,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Site reliability team","depth":26,"bounds":{"left":0.14577793,"top":0.58818835,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.5925778,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"New features requests","depth":25,"bounds":{"left":0.099567816,"top":0.6105347,"width":0.055518616,"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":"New features requests","depth":28,"bounds":{"left":0.11020612,"top":0.61652035,"width":0.032579787,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star New features requests","depth":26,"bounds":{"left":0.14577793,"top":0.61372703,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.6181165,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"InfoSec issues","depth":25,"bounds":{"left":0.099567816,"top":0.6360734,"width":0.055518616,"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":"InfoSec issues","depth":28,"bounds":{"left":0.11020612,"top":0.6420591,"width":0.03324468,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star InfoSec issues","depth":26,"bounds":{"left":0.14577793,"top":0.6392658,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.64365524,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Ready for Customer","depth":25,"bounds":{"left":0.099567816,"top":0.66161215,"width":0.055518616,"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":"Ready for Customer","depth":28,"bounds":{"left":0.11020612,"top":0.6675978,"width":0.032912236,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Ready for Customer","depth":26,"bounds":{"left":0.14577793,"top":0.66480446,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.6691939,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Resolved tickets","depth":25,"bounds":{"left":0.099567816,"top":0.68715084,"width":0.055518616,"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":"Resolved tickets","depth":28,"bounds":{"left":0.11020612,"top":0.69313645,"width":0.033909574,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Resolved tickets","depth":26,"bounds":{"left":0.14577793,"top":0.6903432,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"-","depth":28,"bounds":{"left":0.14876994,"top":0.69473267,"width":0.0019946808,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"View all queues","depth":24,"bounds":{"left":0.095578454,"top":0.7126895,"width":0.059507977,"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":"View all queues","depth":27,"bounds":{"left":0.10621676,"top":0.7186752,"width":0.034906916,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service requests","depth":21,"bounds":{"left":0.09158909,"top":0.73822826,"width":0.06349734,"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 requests","depth":24,"bounds":{"left":0.1022274,"top":0.7442139,"width":0.03756649,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create","depth":22,"bounds":{"left":0.15309176,"top":0.74142057,"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":"Create","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More for service requests","depth":22,"bounds":{"left":0.15442154,"top":0.74142057,"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 for service requests","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Incidents","depth":22,"bounds":{"left":0.09158909,"top":0.76376694,"width":0.06349734,"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":"Incidents","depth":25,"bounds":{"left":0.1022274,"top":0.7697526,"width":0.021276595,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create","depth":23,"bounds":{"left":0.15309176,"top":0.7669593,"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":"Create","depth":25,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More for incidents","depth":23,"bounds":{"left":0.15442154,"top":0.7669593,"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 for incidents","depth":25,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Reports","depth":19,"bounds":{"left":0.09158909,"top":0.7893057,"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":"Reports","depth":22,"bounds":{"left":0.1022274,"top":0.7952913,"width":0.017287234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for reports","depth":20,"bounds":{"left":0.15309176,"top":0.792498,"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 reports","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Operations","depth":19,"bounds":{"left":0.09158909,"top":0.81484437,"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":"Operations","depth":22,"bounds":{"left":0.1022274,"top":0.82083,"width":0.02443484,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for operations","depth":20,"bounds":{"left":0.15309176,"top":0.81803674,"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":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Knowledge Base","depth":19,"bounds":{"left":0.09158909,"top":0.84038305,"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}]...
|
-731553621300928484
|
-8964582591050496892
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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
Notifications
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)
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
Summary
Summary
More actions for summary
More actions for summary
Queues
Queues
Create
Create
More for queues
More for queues
Team Priority
Team Priority
All open tickets
All open tickets
Star All open tickets
-
Unassigned tickets
Unassigned tickets
Star Unassigned tickets
-
Support team Queue
Support team Queue
Star Support team Queue
-
Raised by me
Raised by me
Star Raised by me
-
Assigned to me
Assigned to me
Star Assigned to me
-
Service requests
Service requests
Star Service requests
-
Platform team
Platform team
Star Platform team
-
Processing team
Processing team
Star Processing team
-
Site reliability team
Site reliability team
Star Site reliability team
-
New features requests
New features requests
Star New features requests
-
InfoSec issues
InfoSec issues
Star InfoSec issues
-
Ready for Customer
Ready for Customer
Star Ready for Customer
-
Resolved tickets
Resolved tickets
Star Resolved tickets
-
View all queues
View all queues
Service requests
Service requests
Create
Create
More for service requests
More for service requests
Incidents
Incidents
Create
Create
More for incidents
More for incidents
Reports
Reports
More actions for reports
More actions for reports
Operations
Operations
More actions for operations
More actions for operations
Knowledge Base...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71111
|
NULL
|
0
|
2026-05-22T11:26:39.689108+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449199689_m1.jpg...
|
Firefox
|
Service-Desk - Queues - Platform team - Service sp Service-Desk - Queues - Platform team - Service space - Jira — Work...
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848...
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449199689_m1.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"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":true},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - 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":"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}]...
|
-5812865614758783481
|
6941871837305401797
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app...
|
71109
|
NULL
|
NULL
|
NULL
|
|
71110
|
NULL
|
0
|
2026-05-22T11:26:37.648552+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449197648_m2.jpg...
|
Slack
|
Stoyan Tomov (DM) - Jiminny Inc - 2 new items - Sl Stoyan Tomov (DM) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449197648_m2.jpg...
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity...
|
[{"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"}]...
|
-7457134496557439989
|
-8120564398931363145
|
click
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
rireroycaltVIevMIstorybookmarksProtlles10019WindowInny.dulassian.nellld/servicedesk/oroectsPlatform Sprint 4 Q2 - Platform Te-7 fJy-209121 Fallback mechanism foO JIMINNYJiraJY-20676 delete AJ reports relateJy-20963 fix deleted object impor© Pipelines - jiminny/apps)SevenShores|Hubspot\Exceptions8 JiminnyfJY-208791 Enable users to use thSentry error)* JY.20962 fix deleted obiect imnon# (JY-20813] Twilio number is not relJL New Tah8$ Apps+ ...÷ ...ts =...-Q Search+ Create‹OMOA100% .• HizzMay 14-20.3/E ASK ROVO A ® Lô 0...
|
71108
|
NULL
|
NULL
|
NULL
|
|
71109
|
NULL
|
0
|
2026-05-22T11:26:31.445999+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449191445_m1.jpg...
|
Slack
|
Stoyan Tomov (DM) - Jiminny Inc - 2 new items - Sl Stoyan Tomov (DM) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449191445_m1.jpg...
|
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zs SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode 100644 app/Component/ES/QueuePriorityEnum.phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker 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] (JY-20963-fix-import-on-deleted-entdocker 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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimental feature and may be unstable,useitat yourownrisk.Loaded config default from".php-cs-fixer.dist.php"5691/5691100%APP (..•EDHomeDMsActivityFilesLater...More+→Jiminny ...CHSIICCTIS# general# happy_birthday# jiminny-bg# platform-tickets# product_launches#random# releases# sofia-office# support# thank-yous# the_people_of jimi...° Direct messagesGalya Dimitrova "R. Nikolay Yankov®. Aneliya AngelovaStoyan Tanevdo James Grahamã. Stefka StoyanovaEo Vasil Vasilev. Stoyan Tomov&: Todor StamatovLukas Kovalik y... O: AppsJira CloudToast, 0labl100% <478•Fri 22 May 14:26:30Describe what you are looking forStoyan Tomov6 0• Messagest Add canvasO FilesMore~+здрастиMonday, May 18th ~има мальк проблем с това как и кой може дагледа Al Call Scoring-a в Team Insightsчудя се самотова е за платформ екипа нали?понеже касае само Team Insightsи как се показва тамLukas Kovalik 10:52 AMздрастиидея си нямамами прати при нас, и ще видимпо принцип Al call scoring май беше processingно пак ТI е при насStoyan Tomov 10:54 AMеми и аз така си го тьлкувамреално проблема не е със саматафункционалност на Al Call Scoring-aLukas Kovalik 10:54 AMпрати тогава при насStoyan Tomov 10:54 AMпросто че обикновени юзъри получават грешкакато отидат на Team Insights да видят чартоветеLukas Kovalik 10:54 AMако се наложи ще го преместимMessage Stoyan TomovFixed 0 of 5691 files in 96.077 seconds, 67.00 MBmemory used...
|
NULL
|
-1042284698382204049
|
NULL
|
visual_change
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zs SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode 100644 app/Component/ES/QueuePriorityEnum.phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker 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] (JY-20963-fix-import-on-deleted-entdocker 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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimental feature and may be unstable,useitat yourownrisk.Loaded config default from".php-cs-fixer.dist.php"5691/5691100%APP (..•EDHomeDMsActivityFilesLater...More+→Jiminny ...CHSIICCTIS# general# happy_birthday# jiminny-bg# platform-tickets# product_launches#random# releases# sofia-office# support# thank-yous# the_people_of jimi...° Direct messagesGalya Dimitrova "R. Nikolay Yankov®. Aneliya AngelovaStoyan Tanevdo James Grahamã. Stefka StoyanovaEo Vasil Vasilev. Stoyan Tomov&: Todor StamatovLukas Kovalik y... O: AppsJira CloudToast, 0labl100% <478•Fri 22 May 14:26:30Describe what you are looking forStoyan Tomov6 0• Messagest Add canvasO FilesMore~+здрастиMonday, May 18th ~има мальк проблем с това как и кой може дагледа Al Call Scoring-a в Team Insightsчудя се самотова е за платформ екипа нали?понеже касае само Team Insightsи как се показва тамLukas Kovalik 10:52 AMздрастиидея си нямамами прати при нас, и ще видимпо принцип Al call scoring май беше processingно пак ТI е при насStoyan Tomov 10:54 AMеми и аз така си го тьлкувамреално проблема не е със саматафункционалност на Al Call Scoring-aLukas Kovalik 10:54 AMпрати тогава при насStoyan Tomov 10:54 AMпросто че обикновени юзъри получават грешкакато отидат на Team Insights да видят чартоветеLukas Kovalik 10:54 AMако се наложи ще го преместимMessage Stoyan TomovFixed 0 of 5691 files in 96.077 seconds, 67.00 MBmemory used...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71107
|
NULL
|
0
|
2026-05-22T11:26:25.212007+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449185212_m1.jpg...
|
Slack
|
releases (Channel) - Jiminny Inc - 2 new items - S releases (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449185212_m1.jpg...
|
SlackFileEditViewGoHistoryWindowHelpAPP (IDOCKERDE SlackFileEditViewGoHistoryWindowHelpAPP (IDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode 100644 app/Component/ES/QueuePriorityEnum.phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker 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] (JY-20963-fix-import-on-deleted-entdocker 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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimental feature and may be unstable,useitat yourownrisk.Loaded config default from".php-cs-fixer.dist.php"5691/5691100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MBmemory used• •ED→HomeDMsActivityFilesLaterMore+Jiminny ...CHSIICCTIS# general# happy_birthday# jiminny-bg# platform-tickets# product_launches#random# releases# sofia-office# support# thank-yous# the_people_of_jimi...° Direct messages. Galya Dimitrova "R. Nikolay Yankov®. Aneliya AngelovaStoyan Tanevdo James Grahamã. Stefka StoyanovaEo Vasil Vasilev. Stoyan Tomov&: Todor StamatovLukas Kovalik y... Ol:: AppsJira CloudToast> 0100%8 • Fri 22 May 14:26:24Describe what you are looking for# releases8 226 0• MessagesProject: appC FilesBookmarksM/kan.Today ~J26+10:26:05Tag:View JobGitHub APP1:27 PM4 new commits pushed tomaster byTodorStamatovJiminnycbf9b72b - JY-20955: fix calendar eventduplication checkafe8dceg - Merge branch 'master' into JY-20955-multiple-calendars-events218a8602 - Merge branch 'master' into JY-20955-multiple-calendars-eventsbe560cbf - Merge pull request #12119 fromjiminny/JY-20955-multiple-calendars-eventsjiminny/app | Added by GitHubNewCircleCl APP 1:53 PMDeployment Successful!Project: appWhen:05/22/202610:53:15Tag:View JobMessage #releases+Aa..•...
|
NULL
|
-8621425048485233537
|
NULL
|
click
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPP (IDOCKERDE SlackFileEditViewGoHistoryWindowHelpAPP (IDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode 100644 app/Component/ES/QueuePriorityEnum.phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker 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] (JY-20963-fix-import-on-deleted-entdocker 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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimental feature and may be unstable,useitat yourownrisk.Loaded config default from".php-cs-fixer.dist.php"5691/5691100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MBmemory used• •ED→HomeDMsActivityFilesLaterMore+Jiminny ...CHSIICCTIS# general# happy_birthday# jiminny-bg# platform-tickets# product_launches#random# releases# sofia-office# support# thank-yous# the_people_of_jimi...° Direct messages. Galya Dimitrova "R. Nikolay Yankov®. Aneliya AngelovaStoyan Tanevdo James Grahamã. Stefka StoyanovaEo Vasil Vasilev. Stoyan Tomov&: Todor StamatovLukas Kovalik y... Ol:: AppsJira CloudToast> 0100%8 • Fri 22 May 14:26:24Describe what you are looking for# releases8 226 0• MessagesProject: appC FilesBookmarksM/kan.Today ~J26+10:26:05Tag:View JobGitHub APP1:27 PM4 new commits pushed tomaster byTodorStamatovJiminnycbf9b72b - JY-20955: fix calendar eventduplication checkafe8dceg - Merge branch 'master' into JY-20955-multiple-calendars-events218a8602 - Merge branch 'master' into JY-20955-multiple-calendars-eventsbe560cbf - Merge pull request #12119 fromjiminny/JY-20955-multiple-calendars-eventsjiminny/app | Added by GitHubNewCircleCl APP 1:53 PMDeployment Successful!Project: appWhen:05/22/202610:53:15Tag:View JobMessage #releases+Aa..•...
|
71099
|
NULL
|
NULL
|
NULL
|
|
71108
|
NULL
|
0
|
2026-05-22T11:26:24.807033+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449184807_m2.jpg...
|
Slack
|
releases (Channel) - Jiminny Inc - 2 new items - S releases (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449184807_m2.jpg...
|
FirefoxFileEditVIewMistorbookmarksProtllesToolsWin FirefoxFileEditVIewMistorbookmarksProtllesToolsWindowmelpiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848O JIMINNYR MO A sщ Z• F2May 14:26:24Platform Sprint 4 Q2 - Platform Te-7 fJy-209121 Fallback mechanism fo• JiraJY-20676 delete AJ reports relateJY-20963 fix deleted object impor© Pipelines - jiminny/app)SevenShores|Hubspot\Exceptions8 JiminnyfJY-208791 Enable users to use thSentry error)* JY.20962 fix deleted obiect imnon# (UY-20813] Twilio number is not rel+ New TabQ Search+ CreateE ASK ROVO A ® Lô 0@ For you(• Recenti# Starred8$ AppsQ Spaces+ ...Jiminny (New)( Service-Desk@ SummaryQueues÷ ...v leam Prioritv@ All open tickets =# Unassigned t...-•, Support tea...E Raised by me@ Assigned to ...•, Service requ...10 Platform team& Processing t...a Site reliability….= New features..-j InfoSec issues -@ Ready for Cu... =#, Resolved tick…..= View all queuesE) Service requestsA Incidentsilll Reports@ Operations• Knowledae BaseO CuctomordQ Channels• Email loas< Neveloner eccalationse Slack intearation...
|
NULL
|
3666182374660668484
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditVIewMistorbookmarksProtllesToolsWin FirefoxFileEditVIewMistorbookmarksProtllesToolsWindowmelpiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848O JIMINNYR MO A sщ Z• F2May 14:26:24Platform Sprint 4 Q2 - Platform Te-7 fJy-209121 Fallback mechanism fo• JiraJY-20676 delete AJ reports relateJY-20963 fix deleted object impor© Pipelines - jiminny/app)SevenShores|Hubspot\Exceptions8 JiminnyfJY-208791 Enable users to use thSentry error)* JY.20962 fix deleted obiect imnon# (UY-20813] Twilio number is not rel+ New TabQ Search+ CreateE ASK ROVO A ® Lô 0@ For you(• Recenti# Starred8$ AppsQ Spaces+ ...Jiminny (New)( Service-Desk@ SummaryQueues÷ ...v leam Prioritv@ All open tickets =# Unassigned t...-•, Support tea...E Raised by me@ Assigned to ...•, Service requ...10 Platform team& Processing t...a Site reliability….= New features..-j InfoSec issues -@ Ready for Cu... =#, Resolved tick…..= View all queuesE) Service requestsA Incidentsilll Reports@ Operations• Knowledae BaseO CuctomordQ Channels• Email loas< Neveloner eccalationse Slack intearation...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71106
|
NULL
|
0
|
2026-05-22T11:26:21.165148+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449181165_m2.jpg...
|
Firefox
|
Jira — Work
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37/SRD-6848...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449181165_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Jira
Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Jira","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":true},{"role":"AXStaticText","text":"Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"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.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":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"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.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":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"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.35155627,"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":false},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.37071028,"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 deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.38188347,"width":0.14245346,"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.40343177,"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-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.41460496,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.43774942,"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}]...
|
8208325967486837278
|
-8498451237802806649
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Jira
Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
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...
|
71105
|
NULL
|
NULL
|
NULL
|
|
71105
|
NULL
|
0
|
2026-05-22T11:26:16.498334+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449176498_m2.jpg...
|
Firefox
|
Jira — Work
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449176498_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Jira
Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Jira","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":true},{"role":"AXStaticText","text":"Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"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.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":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"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.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}]...
|
6779109885152656423
|
-4407247625267425145
|
click
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Jira
Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71104
|
NULL
|
0
|
2026-05-22T11:26:14.833729+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449174833_m2.jpg...
|
Firefox
|
Jira — Work
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449174833_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Jira
Jira
Close tab
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...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Jira","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":true},{"role":"AXStaticText","text":"Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
6858102405718186396
|
2474265826870849613
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Jira
Jira
Close tab
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...
|
71103
|
NULL
|
NULL
|
NULL
|
|
71103
|
NULL
|
0
|
2026-05-22T11:26:07.685734+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449167685_m2.jpg...
|
Firefox
|
Service-Desk - Queues - Platform team - Service sp Service-Desk - Queues - Platform team - Service space - Jira — Work...
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449167685_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
New Tab
Customize sidebar
Open Google Gemini (⌃X)...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Service-Desk - Queues - Platform team - Service space - Jira","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":true},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.10721409,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"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.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":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"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.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":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"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.35155627,"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":false},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.37071028,"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 deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.38188347,"width":0.14245346,"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.40343177,"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-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.41460496,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.43774942,"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}]...
|
2574671551428760417
|
-4373439981524225915
|
click
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
New Tab
Customize sidebar
Open Google Gemini (⌃X)...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71102
|
NULL
|
0
|
2026-05-22T11:26:05.106676+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449165106_m2.jpg...
|
Firefox
|
Service-Desk - Queues - Platform team - Service sp Service-Desk - Queues - Platform team - Service space - Jira — Work...
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449165106_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Service-Desk - Queues - Platform team - Service space - Jira","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":true},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.10721409,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-1320934172171515933
|
7081456910771187845
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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...
|
71101
|
NULL
|
NULL
|
NULL
|
|
71101
|
NULL
|
0
|
2026-05-22T11:26:01.954994+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449161954_m2.jpg...
|
Firefox
|
Service-Desk - Queues - Platform team - Service sp Service-Desk - Queues - Platform team - Service space - Jira — Work...
|
1
|
jiminny.atlassian.net/jira/servicedesk/projects/SR jiminny.atlassian.net/jira/servicedesk/projects/SRD/queues/custom/37...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449161954_m2.jpg...
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Top Bar
Top Bar
Sidebar
Sidebar
Main Content
Main Content
Queue
Queue
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
Over 9 Notifications
Over 9 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)
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
Summary
Summary
More actions for summary
More actions for summary
Queues
Queues
Create
Create
More for queues
More for queues
Team Priority
Team Priority
All open tickets
All open tickets
Star All open tickets
15
Unassigned tickets
Unassigned tickets
Star Unassigned tickets
2
Support team Queue
Support team Queue
Star Support team Queue
6
Raised by me
Raised by me
Star Raised by me
0
Assigned to me
Assigned to me
Star Assigned to me
2
Service requests...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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":"Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.10106383,"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":"Service-Desk - Queues - Platform team - Service space - Jira","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":true},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.10721409,"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.1245012,"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":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","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":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.14245346,"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.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":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"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.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":"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.25937748,"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.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":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"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.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":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"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.35155627,"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":false},{"role":"AXRadioButton","text":"JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.37071028,"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 deleted object import by LakyLak · Pull Request #12121 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.38188347,"width":0.14245346,"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.40343177,"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-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.41460496,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.43774942,"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":"Top Bar","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":"Top Bar","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":"Sidebar","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":"Sidebar","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":"AXLink","text":"Queue","depth":10,"bounds":{"left":0.090259306,"top":0.15522745,"width":0.014461436,"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":"Queue","depth":11,"bounds":{"left":0.090259306,"top":0.15522745,"width":0.014461436,"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":"Over 9 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":"Over 9 Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.043882977,"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":false},{"role":"AXMenuButton","text":"Create board","depth":18,"bounds":{"left":0.15508644,"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.16240026,"top":0.25618514,"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 Jiminny (New)","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service-Desk","depth":17,"bounds":{"left":0.08759973,"top":0.27853152,"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":true},{"role":"AXStaticText","text":"Service-Desk","depth":20,"bounds":{"left":0.09823803,"top":0.28451717,"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.14577793,"top":0.28172386,"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 Service-Desk","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Summary","depth":21,"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":"Summary","depth":24,"bounds":{"left":0.1022274,"top":0.31005585,"width":0.021276595,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for summary","depth":22,"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":"More actions for summary","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Queues","depth":21,"bounds":{"left":0.09158909,"top":0.32960895,"width":0.06349734,"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":"Queues","depth":24,"bounds":{"left":0.1022274,"top":0.33559456,"width":0.017121011,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create","depth":22,"bounds":{"left":0.13646941,"top":0.33280128,"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","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More for queues","depth":22,"bounds":{"left":0.14577793,"top":0.33280128,"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 for queues","depth":24,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Team Priority","depth":23,"bounds":{"left":0.095578454,"top":0.35514766,"width":0.059507977,"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":"Team Priority","depth":26,"bounds":{"left":0.10621676,"top":0.36113328,"width":0.029587766,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"All open tickets","depth":25,"bounds":{"left":0.099567816,"top":0.38068634,"width":0.055518616,"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":"All open tickets","depth":28,"bounds":{"left":0.11020612,"top":0.386672,"width":0.034075797,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star All open tickets","depth":26,"bounds":{"left":0.14577793,"top":0.38387868,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"15","depth":28,"bounds":{"left":0.14777261,"top":0.38826814,"width":0.0039893617,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Unassigned tickets","depth":25,"bounds":{"left":0.099567816,"top":0.40622506,"width":0.055518616,"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":"Unassigned tickets","depth":28,"bounds":{"left":0.11020612,"top":0.4122107,"width":0.03307846,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Unassigned tickets","depth":26,"bounds":{"left":0.14577793,"top":0.4094174,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2","depth":28,"bounds":{"left":0.14860372,"top":0.41380686,"width":0.0023271276,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Support team Queue","depth":25,"bounds":{"left":0.099567816,"top":0.43176377,"width":0.055518616,"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":"Support team Queue","depth":28,"bounds":{"left":0.11020612,"top":0.43774942,"width":0.03025266,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Support team Queue","depth":26,"bounds":{"left":0.14577793,"top":0.4349561,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"6","depth":28,"bounds":{"left":0.14860372,"top":0.43934557,"width":0.0023271276,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Raised by me","depth":25,"bounds":{"left":0.099567816,"top":0.45730248,"width":0.055518616,"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":"Raised by me","depth":28,"bounds":{"left":0.11020612,"top":0.4632881,"width":0.029753989,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Raised by me","depth":26,"bounds":{"left":0.14577793,"top":0.46049482,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"0","depth":28,"bounds":{"left":0.1484375,"top":0.46488428,"width":0.0026595744,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Assigned to me","depth":25,"bounds":{"left":0.099567816,"top":0.4828412,"width":0.055518616,"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":"Assigned to me","depth":28,"bounds":{"left":0.11020612,"top":0.4888268,"width":0.03174867,"height":0.029928172},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Star Assigned to me","depth":26,"bounds":{"left":0.14577793,"top":0.48603353,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2","depth":28,"bounds":{"left":0.14860372,"top":0.490423,"width":0.0023271276,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Service requests","depth":25,"bounds":{"left":0.099567816,"top":0.5083799,"width":0.055518616,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-3976577468184770233
|
-9072668984271546747
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 4 Q2 - Platform Team - Scrum Board Platform Sprint 4 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 4 Q2 - Platform Team - Scrum Board - 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
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Close tab
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-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · jiminny/app
JY-20963 fix deleted object import by LakyLak · Pull Request #12121 · 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
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Top Bar
Top Bar
Sidebar
Sidebar
Main Content
Main Content
Queue
Queue
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
Over 9 Notifications
Over 9 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)
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
Summary
Summary
More actions for summary
More actions for summary
Queues
Queues
Create
Create
More for queues
More for queues
Team Priority
Team Priority
All open tickets
All open tickets
Star All open tickets
15
Unassigned tickets
Unassigned tickets
Star Unassigned tickets
2
Support team Queue
Support team Queue
Star Support team Queue
6
Raised by me
Raised by me
Star Raised by me
0
Assigned to me
Assigned to me
Star Assigned to me
2
Service requests...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71100
|
NULL
|
0
|
2026-05-22T11:25:57.477344+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449157477_m2.jpg...
|
Firefox
|
[JY-20912] Fallback mechanism for users with activ [JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira — Work...
|
1
|
jiminny.atlassian.net/browse/JY-20912
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449157477_m2.jpg...
|
FirefoxVIewMistor*Plattorm Sprint 4 Qz - Plattori[ FirefoxVIewMistor*Plattorm Sprint 4 Qz - Plattori[JY-20912] Fallback mechanisr XService-Desk - Queues - Platforn• JY-20676 delete AJ reports relateJy-20963 fix deleted object impor© Pipelines - jiminny/apps)SevenShores|Hubspot\Exceptions8 JiminnyJY-20879) Enable users to use thiSentry error)* JY.20962 fix deleted obiect imnon# (UY-20813] Twilio number is not rel+ New TabbookmarksProtllesToolsWindowmelpny.atlassian.net/browse/JY-20912O JIMINNY@ For you(• Recenti# Starred8$ AppsQ Spaces+ ...Jiminny (New) + ...IWD Platform TeamIID Capture TeamWD Enterprise Stability I…..W Processing TeamIN SE KanbanC Service-Desk= More spaces— FiltersCB Dashboards@ Operations2 Confluence: Teams"= Customise sidebarIQGMOAX 00%E 8• Fri22May 14:25:57E ASK ROVO A ® Lô 0Q SearchSpaces / Jiminny (New) / & sY-12250 / [ JY-20912Fallback mechanism for users with active SF tokens for CRM Matching~ DescriptionWe identified an issue for Tourlane where Twillo calls appear as "Unknown Customer" when the Salesforce admin connection token has expired. In this case, CRMobject matching fails because the system relies only on the admin's Salesforce token.We should implement the same fallback logic currently used for dialers - if the Salesforce admin token is expired or inactive, the system should search for another activeSalesforce token from any user within the same organisation and use it for CRM matching.Acceptance Criteria:• If the Salesforce admin token has expired, the system searches for another active Salesforce token within the organisation.• CRM object matching continues successfully using the fallback token.• Calls should not appear as "Unknown Customer" when another valid Salesforce connection exists in the organisation.• update this in all places - in all CRM related functionalitiesSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityHistory Work logAdd a comment…..Suggest a reply... Who is working on this...? Status update...Pro tip: press (M) to commentnvniay vonunit4 days ago (edited)RF: 1 dav+ CreateBacklog~ * Improve StoryDetailsAssigneeReporterDevelopmentComponentsSub-ProductLabelsStory point estimateStory PointsOrganisationsPriorityFix versionsSprintDaysNeed QA& UnassignedAssign to me@ Adelina Petrova[ Open with VS Code33 Create branch/ Create commitPlatformAdd optionsNoneNoneNoneNone= MediumNonePlatform Sprint 5 Q2ParentCanny LinksNO* Jy-12250 Reduce Churn)Open Canny Links> More fields Original estimate, Time tracking> Automation 4 Rule executions> featureOS Open featureos022....
|
NULL
|
-1393462044324142301
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxVIewMistor*Plattorm Sprint 4 Qz - Plattori[ FirefoxVIewMistor*Plattorm Sprint 4 Qz - Plattori[JY-20912] Fallback mechanisr XService-Desk - Queues - Platforn• JY-20676 delete AJ reports relateJy-20963 fix deleted object impor© Pipelines - jiminny/apps)SevenShores|Hubspot\Exceptions8 JiminnyJY-20879) Enable users to use thiSentry error)* JY.20962 fix deleted obiect imnon# (UY-20813] Twilio number is not rel+ New TabbookmarksProtllesToolsWindowmelpny.atlassian.net/browse/JY-20912O JIMINNY@ For you(• Recenti# Starred8$ AppsQ Spaces+ ...Jiminny (New) + ...IWD Platform TeamIID Capture TeamWD Enterprise Stability I…..W Processing TeamIN SE KanbanC Service-Desk= More spaces— FiltersCB Dashboards@ Operations2 Confluence: Teams"= Customise sidebarIQGMOAX 00%E 8• Fri22May 14:25:57E ASK ROVO A ® Lô 0Q SearchSpaces / Jiminny (New) / & sY-12250 / [ JY-20912Fallback mechanism for users with active SF tokens for CRM Matching~ DescriptionWe identified an issue for Tourlane where Twillo calls appear as "Unknown Customer" when the Salesforce admin connection token has expired. In this case, CRMobject matching fails because the system relies only on the admin's Salesforce token.We should implement the same fallback logic currently used for dialers - if the Salesforce admin token is expired or inactive, the system should search for another activeSalesforce token from any user within the same organisation and use it for CRM matching.Acceptance Criteria:• If the Salesforce admin token has expired, the system searches for another active Salesforce token within the organisation.• CRM object matching continues successfully using the fallback token.• Calls should not appear as "Unknown Customer" when another valid Salesforce connection exists in the organisation.• update this in all places - in all CRM related functionalitiesSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityHistory Work logAdd a comment…..Suggest a reply... Who is working on this...? Status update...Pro tip: press (M) to commentnvniay vonunit4 days ago (edited)RF: 1 dav+ CreateBacklog~ * Improve StoryDetailsAssigneeReporterDevelopmentComponentsSub-ProductLabelsStory point estimateStory PointsOrganisationsPriorityFix versionsSprintDaysNeed QA& UnassignedAssign to me@ Adelina Petrova[ Open with VS Code33 Create branch/ Create commitPlatformAdd optionsNoneNoneNoneNone= MediumNonePlatform Sprint 5 Q2ParentCanny LinksNO* Jy-12250 Reduce Churn)Open Canny Links> More fields Original estimate, Time tracking> Automation 4 Rule executions> featureOS Open featureos022....
|
71097
|
NULL
|
NULL
|
NULL
|
|
71099
|
NULL
|
0
|
2026-05-22T11:25:57.320722+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449157320_m1.jpg...
|
Firefox
|
[JY-20912] Fallback mechanism for users with activ [JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira — Work...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449157320_m1.jpg...
|
SlackFileEditViewGoHistoryWindowHelpAPP (DOCKERDEV SlackFileEditViewGoHistoryWindowHelpAPP (DOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644 .github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete modecreate mode 100644 app/Component/ES/QueuePriorityEnum.phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker 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] (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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimental feature and may be unstable,useitat yourownrisk.Loaded config default from".php-cs-fixer.dist.php"5691/5691100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MBmemory usedHomeDMsActivityFilesLaterMore+•ED→Jiminny ...• Channels# 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 "TP.. Nikolay Yankove. Aneliya Angelova. Stoyan Tanevdo James Grahamã. Stefka StoyanovaEo Vasil Vasilev> 0100% С8 • Fri 22 May 14:25:57Describe what you are looking for# releases8 226 0• MessagesProject: appC FilesBookmarksM/kan.Today ~J26+10:26:05Tag:View JobGitHub APP1:27 PM4 new commits pushed tomaster byTodorStamatovJiminnycbf9b72b - JY-20955: fix calendar eventduplication checkafe8dceg - Merge branch 'master' into JY-20955-multiple-calendars-events218a8602 - Merge branch 'master' into JY-20955-multiple-calendars-eventsbe560cbf - Merge pull request #12119 fromjiminny/JY-20955-multiple-calendars-eventsjiminny/app | Added by GitHubNewCircleCl APP 1:53 PMDeployment Successful!Project: appWhen:05/22/202610:53:15Tag:View JobMessage #releases+Aa..•...
|
NULL
|
4615881499888840892
|
NULL
|
visual_change
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPP (DOCKERDEV SlackFileEditViewGoHistoryWindowHelpAPP (DOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644 .github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete modecreate mode 100644 app/Component/ES/QueuePriorityEnum.phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker 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] (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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimental feature and may be unstable,useitat yourownrisk.Loaded config default from".php-cs-fixer.dist.php"5691/5691100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MBmemory usedHomeDMsActivityFilesLaterMore+•ED→Jiminny ...• Channels# 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 "TP.. Nikolay Yankove. Aneliya Angelova. Stoyan Tanevdo James Grahamã. Stefka StoyanovaEo Vasil Vasilev> 0100% С8 • Fri 22 May 14:25:57Describe what you are looking for# releases8 226 0• MessagesProject: appC FilesBookmarksM/kan.Today ~J26+10:26:05Tag:View JobGitHub APP1:27 PM4 new commits pushed tomaster byTodorStamatovJiminnycbf9b72b - JY-20955: fix calendar eventduplication checkafe8dceg - Merge branch 'master' into JY-20955-multiple-calendars-events218a8602 - Merge branch 'master' into JY-20955-multiple-calendars-eventsbe560cbf - Merge pull request #12119 fromjiminny/JY-20955-multiple-calendars-eventsjiminny/app | Added by GitHubNewCircleCl APP 1:53 PMDeployment Successful!Project: appWhen:05/22/202610:53:15Tag:View JobMessage #releases+Aa..•...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71098
|
NULL
|
0
|
2026-05-22T11:25:54.052610+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449154052_m1.jpg...
|
Firefox
|
[JY-20912] Fallback mechanism for users with activ [JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira — Work...
|
1
|
jiminny.atlassian.net/browse/JY-20912
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449154052_m1.jpg...
|
SlackFileEditViewGoHistoryWindowHelpAPP (DOCKERDEV SlackFileEditViewGoHistoryWindowHelpAPP (DOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644 .github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode 100644 app/Component/ES/QueuePriorityEnum.phpAT ryetusworkersaifrat t: phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistentdebugging tools in any container or image →Learn moreat https:://docs.docker.com/go/debug-cli/failed to connect to thedocker API at [URL_WITH_CREDENTIALS] (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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimentalfeaturebe unstable,useitat yourownrisk.Loaded config default from"-php-cs-fixer5691/5691Firefox100гFixed 0 of 5691 files inHomeDMsActivityFilesLaterMore+•ED→Jiminny ...• Channels# 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 "P.. Nikolay Yankove. Aneliya Angelova. Stoyan Tanevlames Graham.> 0100% С8 • Fri 22 May 14:25:53Describe what you are looking for# releases8 226 0• MessagesProject: appC FilesBookmarksM/kan.Today ~J26+10:26:05Tag:View JobGitHub APP1:27 PM4 new commits pushed tomaster byTodorStamatovJiminnycbf9b72b - JY-20955: fix calendar eventduplication checkafe8dceg - Merge branch 'master' into JY-20955-multiple-calendars-events218a8602 - Merge branch 'master' into JY-20955-multiple-calendars-eventsbe560cbf - Merge pull request #12119 fromjiminny/JY-20955-multiple-calendars-eventsjiminny/app | Added by GitHubNewCircleCI APP 1:53 PMDeployment Successful!Project: appWhen:05/22/202610:53:15Tag:View JobMessage #releasesPS...
|
NULL
|
-5548383547256651776
|
NULL
|
visual_change
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPP (DOCKERDEV SlackFileEditViewGoHistoryWindowHelpAPP (DOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644 .github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode 100644 app/Component/ES/QueuePriorityEnum.phpAT ryetusworkersaifrat t: phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistentdebugging tools in any container or image →Learn moreat https:://docs.docker.com/go/debug-cli/failed to connect to thedocker API at [URL_WITH_CREDENTIALS] (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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimentalfeaturebe unstable,useitat yourownrisk.Loaded config default from"-php-cs-fixer5691/5691Firefox100гFixed 0 of 5691 files inHomeDMsActivityFilesLaterMore+•ED→Jiminny ...• Channels# 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 "P.. Nikolay Yankove. Aneliya Angelova. Stoyan Tanevlames Graham.> 0100% С8 • Fri 22 May 14:25:53Describe what you are looking for# releases8 226 0• MessagesProject: appC FilesBookmarksM/kan.Today ~J26+10:26:05Tag:View JobGitHub APP1:27 PM4 new commits pushed tomaster byTodorStamatovJiminnycbf9b72b - JY-20955: fix calendar eventduplication checkafe8dceg - Merge branch 'master' into JY-20955-multiple-calendars-events218a8602 - Merge branch 'master' into JY-20955-multiple-calendars-eventsbe560cbf - Merge pull request #12119 fromjiminny/JY-20955-multiple-calendars-eventsjiminny/app | Added by GitHubNewCircleCI APP 1:53 PMDeployment Successful!Project: appWhen:05/22/202610:53:15Tag:View JobMessage #releasesPS...
|
71096
|
NULL
|
NULL
|
NULL
|
|
71097
|
NULL
|
0
|
2026-05-22T11:25:51.673730+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449151673_m2.jpg...
|
Firefox
|
[JY-20912] Fallback mechanism for users with activ [JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira — Work...
|
1
|
jiminny.atlassian.net/browse/JY-20912
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449151673_m2.jpg...
|
FirefoxEditVIewMistorbookmarksProtllesPlatform Spr FirefoxEditVIewMistorbookmarksProtllesPlatform Sprint 4 Q2 - Platform Te[JY-20912] Fallback mechanisr XService-Desk - Queues - Platform• JY-20676 delete AJ reports relateJy-20963 fix deleted object impor© Pipelines - jiminny/apps)SevenShores|Hubspot\Exceptions8 JiminnyJY-20879) Enable users to use thiSentry error)* JY.20962 fix deleted obiect imnon# (UY-20813] Twilio number is not rel+ New TabToolsWindowmelpny.atlassian.net/browse/JY-20912O JIMINNY@ For you(• Recenti# Starred8$ AppsQ Spaces+ ...Jiminny (New) + ...IWD Platform TeamIID Capture TeamWD Enterprise Stability I…..W Processing TeamIN SE KanbanC Service-Desk= More spaces— FiltersCB Dashboards@ Operations2 Confluence: Teams"= Customise sidebarIQGMOAX 00%E • Fri22May 14:25:51E ASK ROVO A ® Lô 0Q SearchSpaces / Jiminny (New) / & sY-12250 / [ JY-20912Fallback mechanism for users with active SF tokens for CRM Matching~ DescriptionWe identified an issue for Tourlane where Twillo calls appear as "Unknown Customer" when the Salesforce admin connection token has expired. In this case, CRMobject matching fails because the system relies only on the admin's Salesforce token.We should implement the same fallback logic currently used for dialers - if the Salesforce admin token is expired or inactive, the system should search for another activeSalesforce token from any user within the same organisation and use it for CRM matching.Acceptance Criteria:• If the Salesforce admin token has expired, the system searches for another active Salesforce token within the organisation.• CRM object matching continues successfully using the fallback token.• Calls should not appear as "Unknown Customer" when another valid Salesforce connection exists in the organisation.• update this in all places - in all CRM related functionalitiesSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityHistory Work logAdd a comment…..Suggest a reply... Who is working on this...? Status update...Pro tip: press (M) to commentnvniay vonunit4 days ago (edited)RF: 1 dav+ CreateBacklog~ * Improve StoryDetailsAssigneeReporterDevelopmentComponentsSub-ProductLabelsStory point estimateStory PointsOrganisationsPriorityFix versionsSprintDaysNeed QA& UnassignedAssign to me@ Adelina Petrova[ Open with VS Code33 Create branch/ Create commitPlatformAdd optionsNoneNoneNoneNone= MediumNonePlatform Sprint 5 Q2ParentCanny LinksNO* Jy-12250 Reduce Churn)Open Canny Links> More fields Original estimate, Time tracking> Automation 4 Rule executions> featureOS Open featureos022....
|
NULL
|
5147626459785116881
|
NULL
|
app_switch
|
ocr
|
NULL
|
FirefoxEditVIewMistorbookmarksProtllesPlatform Spr FirefoxEditVIewMistorbookmarksProtllesPlatform Sprint 4 Q2 - Platform Te[JY-20912] Fallback mechanisr XService-Desk - Queues - Platform• JY-20676 delete AJ reports relateJy-20963 fix deleted object impor© Pipelines - jiminny/apps)SevenShores|Hubspot\Exceptions8 JiminnyJY-20879) Enable users to use thiSentry error)* JY.20962 fix deleted obiect imnon# (UY-20813] Twilio number is not rel+ New TabToolsWindowmelpny.atlassian.net/browse/JY-20912O JIMINNY@ For you(• Recenti# Starred8$ AppsQ Spaces+ ...Jiminny (New) + ...IWD Platform TeamIID Capture TeamWD Enterprise Stability I…..W Processing TeamIN SE KanbanC Service-Desk= More spaces— FiltersCB Dashboards@ Operations2 Confluence: Teams"= Customise sidebarIQGMOAX 00%E • Fri22May 14:25:51E ASK ROVO A ® Lô 0Q SearchSpaces / Jiminny (New) / & sY-12250 / [ JY-20912Fallback mechanism for users with active SF tokens for CRM Matching~ DescriptionWe identified an issue for Tourlane where Twillo calls appear as "Unknown Customer" when the Salesforce admin connection token has expired. In this case, CRMobject matching fails because the system relies only on the admin's Salesforce token.We should implement the same fallback logic currently used for dialers - if the Salesforce admin token is expired or inactive, the system should search for another activeSalesforce token from any user within the same organisation and use it for CRM matching.Acceptance Criteria:• If the Salesforce admin token has expired, the system searches for another active Salesforce token within the organisation.• CRM object matching continues successfully using the fallback token.• Calls should not appear as "Unknown Customer" when another valid Salesforce connection exists in the organisation.• update this in all places - in all CRM related functionalitiesSubtasksAdd subtaskLinked work itemsAdd linked work item~ ActivityHistory Work logAdd a comment…..Suggest a reply... Who is working on this...? Status update...Pro tip: press (M) to commentnvniay vonunit4 days ago (edited)RF: 1 dav+ CreateBacklog~ * Improve StoryDetailsAssigneeReporterDevelopmentComponentsSub-ProductLabelsStory point estimateStory PointsOrganisationsPriorityFix versionsSprintDaysNeed QA& UnassignedAssign to me@ Adelina Petrova[ Open with VS Code33 Create branch/ Create commitPlatformAdd optionsNoneNoneNoneNone= MediumNonePlatform Sprint 5 Q2ParentCanny LinksNO* Jy-12250 Reduce Churn)Open Canny Links> More fields Original estimate, Time tracking> Automation 4 Rule executions> featureOS Open featureos022....
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71096
|
NULL
|
0
|
2026-05-22T11:25:51.572325+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449151572_m1.jpg...
|
Firefox
|
[JY-20912] Fallback mechanism for users with activ [JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira — Work...
|
1
|
jiminny.atlassian.net/browse/JY-20912
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449151572_m1.jpg...
|
SlackFileEditViewGoHistoryWindowHelpAPP (DOCKER₴81 SlackFileEditViewGoHistoryWindowHelpAPP (DOCKER₴81DEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644 .github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete modecreate mode 100644 app/Component/ES/QueuePriorityEnum.phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker 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] (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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimental feature and may be unstable, useat yourownrisk.Loaded config default from".php-cs-fixer.dist.php"5691/5691100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MBmemory usedHomeDMsActivityFilesLaterMore+•ED→Jiminny ...• Channels# 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 "TP.. Nikolay Yankove. Aneliya Angelova2 Stoyan Tanevdo James Grahamã. Stefka StoyanovaEo Vasil VasilevBA с> 0100% С8 • Fri 22 May 14:25:51Describe what you are looking for# releases8 226 0• MessagesProject: appC FilesBookmarksM/kan.Today ~J26+10:26:05Tag:View JobGitHub APP1:27 PM4 new commits pushed tomaster byTodorStamatovJiminnycbf9b72b - JY-20955: fix calendar eventduplication checkafe8dceg - Merge branch 'master' into JY-20955-multiple-calendars-events218a8602 - Merge branch 'master' into JY-20955-multiple-calendars-eventsbe560cbf - Merge pull request #12119 fromjiminny/JY-20955-multiple-calendars-eventsjiminny/app | Added by GitHubNewCircleCl APP 1:53 PMDeployment Successful!Project: appWhen:05/22/202610:53:15Tag:View JobMessage #releases+Aa..•...
|
NULL
|
4727541818492562410
|
NULL
|
app_switch
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPP (DOCKER₴81 SlackFileEditViewGoHistoryWindowHelpAPP (DOCKER₴81DEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Activity/RingCentral/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/ServiceTest.phptests/Unit/Services/Activity/TwilioVideo/TwilioVideoMetadataHandlerTest.phptests/Unit/Services/Kiosk/AutomatedReports/AutomatedReportsServiceTest.phptests/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 100644 .github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode 100644 app/Component/ES/Processor/BatchStatusInterface.phpcreatemode 100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete modecreate mode 100644 app/Component/ES/QueuePriorityEnum.phpcreate mode 100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Ascreatemode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode 100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode 100644 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-entdocker 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] (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 Ruminski and contributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files perprocess.Parallel runner is an experimental feature and may be unstable, useat yourownrisk.Loaded config default from".php-cs-fixer.dist.php"5691/5691100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MBmemory usedHomeDMsActivityFilesLaterMore+•ED→Jiminny ...• Channels# 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 "TP.. Nikolay Yankove. Aneliya Angelova2 Stoyan Tanevdo James Grahamã. Stefka StoyanovaEo Vasil VasilevBA с> 0100% С8 • Fri 22 May 14:25:51Describe what you are looking for# releases8 226 0• MessagesProject: appC FilesBookmarksM/kan.Today ~J26+10:26:05Tag:View JobGitHub APP1:27 PM4 new commits pushed tomaster byTodorStamatovJiminnycbf9b72b - JY-20955: fix calendar eventduplication checkafe8dceg - Merge branch 'master' into JY-20955-multiple-calendars-events218a8602 - Merge branch 'master' into JY-20955-multiple-calendars-eventsbe560cbf - Merge pull request #12119 fromjiminny/JY-20955-multiple-calendars-eventsjiminny/app | Added by GitHubNewCircleCl APP 1:53 PMDeployment Successful!Project: appWhen:05/22/202610:53:15Tag:View JobMessage #releases+Aa..•...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71095
|
NULL
|
0
|
2026-05-22T11:25:46.088762+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449146088_m2.jpg...
|
Slack
|
releases (Channel) - Jiminny Inc - 2 new items - S releases (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449146088_m2.jpg...
|
Switch workspaces… (Jiminny Inc) Has new messages
|
[{"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}]...
|
7221609583870463791
|
-8138701388860278221
|
app_switch
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
FV faVsco.js°9 master k >Proiect(c) PlanhatActivityListener.pnpReindexForAccountListener.phpCo kelndexrorcontdetListener.onp© ReindexForGroupListener.phpCo kelndexrorLedoListener.onpNo kelnoexrorvpeonunityListener.ono© ReindexForUserListener.php© ResolveProvider.php© SendExportEmail.php© SetupIntegrationAction.php© UserPilotActivityListener.php›U AuthenticationU Automaredkeporisu Calendars0 crmDealRisks0 ElasticSearchGrouosMailboxM Nudaes> M UserPilot(C) CreateNuddeCreatedEvent.oho› M Ooportunitiesm Plavbooksc) Attachl avout nhol© ChangeSidekickSettings.php© CreatePlaybookCreatedEvent.phpv D Playlists>@ Activitiesv M Planhat© CreateActivityAddedToPlaylistEvent.p>@ UserPilotv @ Teams€ ImportGroupPlaylistSharesListener.php>@ UserPilot© CreateDefaultJobTitles.phpC) CreateDefaultLavouts.phoc) createTeamFolders.ohgCreate TwilioSubAccount.php9 Create TwilioSubAccountOnUodate.ohoC)DeactivateTeamAction.onoCHubsootAooUninstallListener.oho©InstallinstantinviteCapableProviders.ohpC) RemoveSlackBot.ohvC)SvncintercomComoanv.ohoC) SvncPlanhatCombanv.oho@UnsubccriheTeam=ventsAction.nhnYc) UndateSalecforceAccount nhniM Transcrintionv Mllserssavercuvity.ongosyncriannat.pnp© SyncToPlanhat.php© ActivityLeadConverted.php©PlaybackController.phppnp api.phpC) CreateSmsSentEvent.phpC) PlanhatActivityListener.pho(C) AskAnythingPromptService.pho(C) AutomatedReportsRepositorv.php(C) AutomatedReportsCommand.phpphp api v2.phpC) RequestGenerateReportJob.pho(C) AutomatedReportResult.php©AutomatedReport.phpuse Jiminny Models Lead"use Jiminny Models Nudge:use Jiminny Models Plavbookcategory.use Jiminny Models Plavuistiuse Jiminny Models Stage:use Jiminny Repositories \CoachingFeedbackRepository:use Jiminnv Renositories ElasticActivitvRenositorv:use Jiminnv Renositories TeamPenository:use Jiminny Rules\CrmReference;luse liminnv Rules Mul +idimencional ArnavMayCharRuil e:use Jiminny\Services \ActivityService;use Jiminny\Services\Activity\Crm0wnerResolver;use Jiminny\Services\Crm\ProviderRegistry:Accept Rejectuse semanny oervaces rlaybackoervaceuse Jininhy services userserviceuse Jiminny\VO\Repository\OnDemandActivitySearch\Criteriause rsr Log Loggerinterraceuse kansey uuld uuld,use sentryfinal class ActivityController extends Controller implements CommentContextInterface// Number of minutes to look back on activities. i.e. a timeout on activity durationorivate const int LOOK BACK = 1801100 đpublic function __constructprivate ProviderRegistry SproviderRegistry,private ActivityService SactivityServiceResponse Sresponselprivate UserService SusenService,private ActivitySearch\Service \ActivitySearch SactivitySearch,private NudgeFactoryInterface SnudgeFactory,private ActivityCommentService SactivityCommentServiceprivate LoggerInterface Sloggerprivate readonly CoachingFeedbackRepository $coachingFeedbackRepository.private readonly TeamRepository $teamRepository,= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD](C) MatchCrmData.onp© UpdateCrmFieldDataJob.php# console SlAGiNGuse ILLuminate Contracts Queue Shoulduueue:final readonly class UodateSalesforce ccount imolements Shou doueueoublic function constructiorivate ProviderRegistry ScrmRegistry..private LoggerInterface $logger,D1..}public function handle(TeamCreated|TeamUpdated Sevent): void$teamCreator = $event->teamCreator;if (Sevent->team->account_id === null || $teamCreator === null) {ScrmService = Sthis->crmRegistry->oetSteamCreaton->aetteamO->aetCrmConfzouration@->oetProviderNamesteam = steamureator->oereamorSintearationAdmin = SteamCreator-›iscrmRequiredo ? Steamcreator : Steam->aet0wner@:if (SteamCreator->isCrmRequiredO)scrmservice-›secusersceancreacorr} else {scrmservice->setuserreamcreator->oetleamo->qetuwnero.tryfScrmResolver = app( abstract: Crm0wnerResolver::class.'inteqratzonAdmin' = Sinteqrat1onAdmin'providerSlug' => Steam->getCrmConfigurationO->qetProviderNameOScrmService = $crmResolver->prepareCrmService@:} catch (Exception $e) {Sthis->loager->info('Attemot to resolve CRM service for 'Sevent->team-›cetluldo.' tarled.".'excention' => se->cetcodeo.tny s'Jiminny_Client_ID__c' => Sevent->team->getUuidO'Tier__c' => Sevent->team->getTier->getTitleO.100% 2• Fri 22 May 14:25:45AskJiminnyReportActivityServiceTest -CascadeInvestigate Foreign KeSalestorce Token Fallb+0 ..• UpdateCrmFieldDataJob.php• CreateActivityFromUploadService.php• CreateActivityFromUploadService.php+6-2Now the medium orioritv itemsPoad UndateSalesforceAccount.png• UpdateSalesforceAccount.php• UpdateSalesforceAccount.php+16 -6Now the ActivityController opportunity sync:Exolored ActivitvController.oho and searched 2 aueries^ ActivitvController.nhnActivitvController.ohvl+7-76/6 tacke doneAll 6 nlaces have been undated to use Crmlwne rRecolver with fallback to other team members' tokens when theSummary of ChangesHigh Priority - Same root cause as Twilio1. MatchCrmData.php: 179-194 - Critical job for matching CRM data on dialer recordings2. HydrateCrmDataBvExternalCallIdJob.oho:60-76 - Hvdrates CRM data bv external call ID (Amazon Connect)3. UpdateCrmFieldDataJob.php:202-217 - Updates CRM field data on activities4. CreateActivitvFromUoloadService.oho:113-119 - Parses CRM records for uoloaded recordinasMedium Priority5. UpdateSalesforceAccount.php: 28-44 - Updates Salesforce account on team changes6. ActivitvController.oho: 1177-1192 - Svncs opportunity on activity view.Pattern UsedCosh loastinn nau1. Creates a CrmOwnerResolver with the team and intended admin2. Calls prepareCrmServiceo which tries the admin first. then falls back to team members with active tokens orderecView allapp/Listeners/Teams/# UpdateSalesforceAccount.pho +16-5app/Jobs/Activity/Import/@ MatchCrmData.php +9 -4app/Component/Uploader/Services/ D CreateActivityFromUploadService.php +7 -2annllohe/Activitvllmnort/ MLindatoCrmGioldData.lob.nhot8ann/lobs/ActivitvlImnort/ M HvdrateCrmDataRvSyternalCallid.loh.ohv18gapo/Htto/Controllers/AP//[M ActivitvController.ohv +8* Reiect alliiAccent allAsk anvthina (&4-L)<> Code Claude Qnus 4.7 MediumW Windsurf Team1-1.UTE.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71094
|
NULL
|
0
|
2026-05-22T11:25:31.077755+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449131077_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449131077_m2.jpg...
|
18PhostormVIewINavicarecodeFV faVsco.js?9 masterro 18PhostormVIewINavicarecodeFV faVsco.js?9 masterroledey(c) PlanhatActivityListener.pnpReindexForAccountListener.phpCo kelndexrorcontdetListener.onp© ReindexForGroupListener.phpCo kelndexrorLedoListener.onpNo kelnoexrorvpeonunityListener.ono© ReindexForUserListener.php© ResolveProvider.php© SendExportEmail.php© SetupIntegrationAction.php© UserPilotActivityListener.php›U Authenticationu Automaredkeporisu Calendars0 crmDealRisksGrouosMailboxm Nudaes> M UserPilot(C) CreateNudaeCreatedEvent.oho› M Ooportunitiesm Plavbooksc) Attachl avout nhol© ChangeSidekickSettings.php© CreatePlaybookCreatedEvent.phpv D Playlists>@ Activitiesv M Planhat© CreateActivityAddedToPlaylistEvent.p>@ UserPilotv @ Teams€ ImportGroupPlaylistSharesListener.php>@ UserPilot© CreateDefaultJobTitles.phpC) CreateDefaultLavouts.phoc) createTeamFolders.ohgCreate TwilioSubAccount.phpP CreateTwilioSubAccountOnUodate.ohpC)DeactivateTeamAction.onoCHubsootAooUninstallListener.oho©InstallinstantinviteCapableProviders.ohpC) RemoveSlackBot.ohvC)SvncintercomComoanv.ohoC) SvncPlanhatCombanv.oho@UnsubccriheTeam=ventsAction.nhnYc) UndateSalecforceAccount nhniM Transcrintionv Mllsers•PurgeLookupcache.pnposyncriannat.pnp© SyncToPlanhat.php©PlaybackController.phppnp api.phpC) CreateSmsSentEvent.phpC) PlanhatActivityListener.pho(C) AskAnythingPromptService.pho(C) AutomatedReportsRepositorv.php(C) AutomatedReportsCommand.phpphp api v2.phpC) RequestGenerateReportJob.pho(C) AutomatedReportResult.php©AutomatedReport.ohpuse Jiminny Models Lead"use Jiminny Models Nudge:use Jiminny Models Plavbookcategory.use Jiminny Models Plavuistiuse Jiminny Models Stage:use Jiminny Repositories \CoachingFeedbackRepository:use Jiminnv Renositories ElasticActivitvRenository:use Jiminnv Renositories TeamPenository:use Jiminny Rules\CrmReference;luse liminnv Rules Mul +idimencional ArnavMayCharRuile:use Jiminny\Services \ActivityService;use Jiminny\Services\Activity\Crm0wnerResolver;use Jiminny\Services\Crm\ProviderRegistry:Accept Rejectuse semanny oervaces rlaybackoervaceuse Jininhy services userserviceuse Jiminny\VO\Repository\OnDemandActivitySearch\Criteriause rsr Log Loggerinterraceuse Ramsey Uuid Uuid:use sentryfinal class ActivityController extends Controller implements CommentContextInterface// Number of minutes to look back on activities. i.e. a timeout on activity durationorivate const int LOOK BACK = 1801100 đpublic function -_construct(private ProviderRegistry SproviderRegistry,private ActivityService SactivityServiceResponse Sresponselprivate UserService SusenService,private ActivitySearch\Service \ActivitySearch SactivitySearch,private NudgeFactoryInterface SnudgeFactory,private ActivityCommentService SactivityCommentServiceprivate LoggerInterface Sloggerprivate readonly CoachingFeedbackRepository $coachingFeedbackRepository.private readonly TeamRepository $teamRepository,= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD](C) MatchCrmData.onp© UpdateCrmFieldDataJob.php# console SlAGiNGuse ILLuminate Contracts Queue Shoulduueue:final readonly class UodateSalesforce ccount imolements Shou doueueoublic functionorivate ProviderRegistry ScrmRegistry..private LoggerInterface $logger,D1..}public function handle(TeamCreated|TeamUpdated Sevent): void$teamCreator = $event->teamCreator;if (Sevent->team->account_id === null || $teamCreator === null) {ScrmService = Sthis->crmRegistry->oetSteamCreaton->aetteamO->aetCrmConfzouration@->oetProviderNamesteam = steamureator->oereamorSintearationAdmin = Ste•->isCrmRequiredO 2 SteamCreator : Steam->aetOwnerO:if (SteamCreator->isCrmRequired)scrmservice-›secusersceancreacorr} else {scrmservice->setuserreamcreator->oetleamo->qetuwnero.tryfScrmResolver = app( abstract: Crm0wnerResolver::class.'providerSlug' => Steam->qetCrmConfiqurationO->getProviderNameOcatch(Exception se)Sthis->loager->info('Attemot to resolve CRM service for •Sevent->team-›cetluldo.' tarled.".'excention' => se->cetcodeo.tny sSevent->team->account->aptCrmPnovidenTd0_X Reiect Filetnaxa() + 5 of 6 files →100% 152• Fri 22 May 14:25:31CascadeInvestigate Foreign KeSalestorce Token Fallb+0 ..• CreateActivityFromUploadService.php• CreateActivityFromUploadService.php+6 -2Now the medium orioritv itemsPoad UndateSalesforceAccount.png• UpdateSalesforceAccount.php• UpdateSalesforceAccount.php+16 -6Now the ActivityController opportunity sync:Exolored ActivitvController.oho and searched 2 aueries^ ActivitvController.nhnActivitvController.ohvl+7-76/6 tacke doneAll 6 nlaces have been undated to use Crmlwne rRecolver with fallback to other team members' tokens when theSummary of ChangesHigh Priority - Same root cause as Twilio1. MatchCrmData.php: 179-194 - Critical job for matching CRM data on dialer recordings2. HydrateCrmDataBvExternalCallIdJob.oho:60-76 - Hvdrates CRM data bv external call ID (Amazon Connect)3. UpdateCrmFieldDataJob.php:202-217 - Updates CRM field data on activities4. CreateActivitvFromUoloadService.oho:113-119 - Parses CRM records for uoloaded recordinasMedium Priority5. UpdateSalesforceAccount.php: 28-44 - Updates Salesforce account on team changes6. ActivityController.oho: 1177-1192 - Svncs opportunity on activity view.Pattern UsedCosh loastinn nau1. Creates a CrmOwnerResolver with the team and intended admin2. Calls prepareCrmServiceo which tries the admin first. then falls back to team members with active tokens orderecView allapp/Listeners/Teams/# UpdateSalesforceAccount.pho +16-5app/Jobs/Activity/Import/@ MatchCrmData.php +9 -4app/Component/Uploader/Services/ D CreateActivityFromUploadService.php +7 -2annllohe/Activitvllmnort/ MLIndatoCrmGioldData.lob.nhntg.ann/lobs/Activitv/Imnort/ M HvdrateCrmDataRvSyternalCallid.loh.ohn18gapo/Htto/Controllers/AP//[M ActivitvController.ohv +8* Reiect alliiAccent alliAsk anvthina (&4-L)<> Code Claude Qnus 1.7 MediumPo. 4 spac...
|
NULL
|
-5934816405611162374
|
NULL
|
app_switch
|
ocr
|
NULL
|
18PhostormVIewINavicarecodeFV faVsco.js?9 masterro 18PhostormVIewINavicarecodeFV faVsco.js?9 masterroledey(c) PlanhatActivityListener.pnpReindexForAccountListener.phpCo kelndexrorcontdetListener.onp© ReindexForGroupListener.phpCo kelndexrorLedoListener.onpNo kelnoexrorvpeonunityListener.ono© ReindexForUserListener.php© ResolveProvider.php© SendExportEmail.php© SetupIntegrationAction.php© UserPilotActivityListener.php›U Authenticationu Automaredkeporisu Calendars0 crmDealRisksGrouosMailboxm Nudaes> M UserPilot(C) CreateNudaeCreatedEvent.oho› M Ooportunitiesm Plavbooksc) Attachl avout nhol© ChangeSidekickSettings.php© CreatePlaybookCreatedEvent.phpv D Playlists>@ Activitiesv M Planhat© CreateActivityAddedToPlaylistEvent.p>@ UserPilotv @ Teams€ ImportGroupPlaylistSharesListener.php>@ UserPilot© CreateDefaultJobTitles.phpC) CreateDefaultLavouts.phoc) createTeamFolders.ohgCreate TwilioSubAccount.phpP CreateTwilioSubAccountOnUodate.ohpC)DeactivateTeamAction.onoCHubsootAooUninstallListener.oho©InstallinstantinviteCapableProviders.ohpC) RemoveSlackBot.ohvC)SvncintercomComoanv.ohoC) SvncPlanhatCombanv.oho@UnsubccriheTeam=ventsAction.nhnYc) UndateSalecforceAccount nhniM Transcrintionv Mllsers•PurgeLookupcache.pnposyncriannat.pnp© SyncToPlanhat.php©PlaybackController.phppnp api.phpC) CreateSmsSentEvent.phpC) PlanhatActivityListener.pho(C) AskAnythingPromptService.pho(C) AutomatedReportsRepositorv.php(C) AutomatedReportsCommand.phpphp api v2.phpC) RequestGenerateReportJob.pho(C) AutomatedReportResult.php©AutomatedReport.ohpuse Jiminny Models Lead"use Jiminny Models Nudge:use Jiminny Models Plavbookcategory.use Jiminny Models Plavuistiuse Jiminny Models Stage:use Jiminny Repositories \CoachingFeedbackRepository:use Jiminnv Renositories ElasticActivitvRenository:use Jiminnv Renositories TeamPenository:use Jiminny Rules\CrmReference;luse liminnv Rules Mul +idimencional ArnavMayCharRuile:use Jiminny\Services \ActivityService;use Jiminny\Services\Activity\Crm0wnerResolver;use Jiminny\Services\Crm\ProviderRegistry:Accept Rejectuse semanny oervaces rlaybackoervaceuse Jininhy services userserviceuse Jiminny\VO\Repository\OnDemandActivitySearch\Criteriause rsr Log Loggerinterraceuse Ramsey Uuid Uuid:use sentryfinal class ActivityController extends Controller implements CommentContextInterface// Number of minutes to look back on activities. i.e. a timeout on activity durationorivate const int LOOK BACK = 1801100 đpublic function -_construct(private ProviderRegistry SproviderRegistry,private ActivityService SactivityServiceResponse Sresponselprivate UserService SusenService,private ActivitySearch\Service \ActivitySearch SactivitySearch,private NudgeFactoryInterface SnudgeFactory,private ActivityCommentService SactivityCommentServiceprivate LoggerInterface Sloggerprivate readonly CoachingFeedbackRepository $coachingFeedbackRepository.private readonly TeamRepository $teamRepository,= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD](C) MatchCrmData.onp© UpdateCrmFieldDataJob.php# console SlAGiNGuse ILLuminate Contracts Queue Shoulduueue:final readonly class UodateSalesforce ccount imolements Shou doueueoublic functionorivate ProviderRegistry ScrmRegistry..private LoggerInterface $logger,D1..}public function handle(TeamCreated|TeamUpdated Sevent): void$teamCreator = $event->teamCreator;if (Sevent->team->account_id === null || $teamCreator === null) {ScrmService = Sthis->crmRegistry->oetSteamCreaton->aetteamO->aetCrmConfzouration@->oetProviderNamesteam = steamureator->oereamorSintearationAdmin = Ste•->isCrmRequiredO 2 SteamCreator : Steam->aetOwnerO:if (SteamCreator->isCrmRequired)scrmservice-›secusersceancreacorr} else {scrmservice->setuserreamcreator->oetleamo->qetuwnero.tryfScrmResolver = app( abstract: Crm0wnerResolver::class.'providerSlug' => Steam->qetCrmConfiqurationO->getProviderNameOcatch(Exception se)Sthis->loager->info('Attemot to resolve CRM service for •Sevent->team-›cetluldo.' tarled.".'excention' => se->cetcodeo.tny sSevent->team->account->aptCrmPnovidenTd0_X Reiect Filetnaxa() + 5 of 6 files →100% 152• Fri 22 May 14:25:31CascadeInvestigate Foreign KeSalestorce Token Fallb+0 ..• CreateActivityFromUploadService.php• CreateActivityFromUploadService.php+6 -2Now the medium orioritv itemsPoad UndateSalesforceAccount.png• UpdateSalesforceAccount.php• UpdateSalesforceAccount.php+16 -6Now the ActivityController opportunity sync:Exolored ActivitvController.oho and searched 2 aueries^ ActivitvController.nhnActivitvController.ohvl+7-76/6 tacke doneAll 6 nlaces have been undated to use Crmlwne rRecolver with fallback to other team members' tokens when theSummary of ChangesHigh Priority - Same root cause as Twilio1. MatchCrmData.php: 179-194 - Critical job for matching CRM data on dialer recordings2. HydrateCrmDataBvExternalCallIdJob.oho:60-76 - Hvdrates CRM data bv external call ID (Amazon Connect)3. UpdateCrmFieldDataJob.php:202-217 - Updates CRM field data on activities4. CreateActivitvFromUoloadService.oho:113-119 - Parses CRM records for uoloaded recordinasMedium Priority5. UpdateSalesforceAccount.php: 28-44 - Updates Salesforce account on team changes6. ActivityController.oho: 1177-1192 - Svncs opportunity on activity view.Pattern UsedCosh loastinn nau1. Creates a CrmOwnerResolver with the team and intended admin2. Calls prepareCrmServiceo which tries the admin first. then falls back to team members with active tokens orderecView allapp/Listeners/Teams/# UpdateSalesforceAccount.pho +16-5app/Jobs/Activity/Import/@ MatchCrmData.php +9 -4app/Component/Uploader/Services/ D CreateActivityFromUploadService.php +7 -2annllohe/Activitvllmnort/ MLIndatoCrmGioldData.lob.nhntg.ann/lobs/Activitv/Imnort/ M HvdrateCrmDataRvSyternalCallid.loh.ohn18gapo/Htto/Controllers/AP//[M ActivitvController.ohv +8* Reiect alliiAccent alliAsk anvthina (&4-L)<> Code Claude Qnus 1.7 MediumPo. 4 spac...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
71093
|
NULL
|
0
|
2026-05-22T11:23:59.384324+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449039384_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449039384_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71092
|
NULL
|
0
|
2026-05-22T11:23:59.101121+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449039101_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449039101_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71091
|
NULL
|
0
|
2026-05-22T11:23:28.965017+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449008965_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449008965_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71090
|
NULL
|
0
|
2026-05-22T11:23:28.825825+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449008825_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779449008825_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71089
|
NULL
|
0
|
2026-05-22T11:22:58.401055+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448978401_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448978401_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71088
|
NULL
|
0
|
2026-05-22T11:22:58.381441+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448978381_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448978381_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71086
|
NULL
|
0
|
2026-05-22T11:22:27.912288+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448947912_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448947912_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71087
|
NULL
|
0
|
2026-05-22T11:22:27.867742+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448947867_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448947867_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71084
|
NULL
|
0
|
2026-05-22T11:21:57.388930+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448917388_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448917388_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71085
|
NULL
|
0
|
2026-05-22T11:21:57.374182+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448917374_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448917374_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71082
|
NULL
|
0
|
2026-05-22T11:21:26.949146+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448886949_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448886949_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71083
|
NULL
|
0
|
2026-05-22T11:21:26.873116+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448886873_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448886873_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71081
|
NULL
|
0
|
2026-05-22T11:20:56.543355+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448856543_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448856543_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71080
|
NULL
|
0
|
2026-05-22T11:20:56.291499+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448856291_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448856291_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71079
|
NULL
|
0
|
2026-05-22T11:20:26.049716+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448826049_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448826049_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71078
|
NULL
|
0
|
2026-05-22T11:20:25.707308+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448825707_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448825707_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71077
|
NULL
|
0
|
2026-05-22T11:19:55.568279+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448795568_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448795568_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71076
|
NULL
|
0
|
2026-05-22T11:19:55.178370+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448795178_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448795178_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71075
|
NULL
|
0
|
2026-05-22T11:19:25.193414+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448765193_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448765193_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71074
|
NULL
|
0
|
2026-05-22T11:19:24.753940+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448764753_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448764753_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71073
|
NULL
|
0
|
2026-05-22T11:18:54.933290+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448734933_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448734933_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71072
|
NULL
|
0
|
2026-05-22T11:18:54.300632+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448734300_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448734300_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71071
|
NULL
|
0
|
2026-05-22T11:18:24.358112+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448704358_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448704358_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71070
|
NULL
|
0
|
2026-05-22T11:18:23.693722+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448703693_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448703693_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|
|
71069
|
NULL
|
0
|
2026-05-22T11:17:53.932480+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448673932_m1.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448673932_m1.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"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.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71049
|
NULL
|
NULL
|
NULL
|
|
71068
|
NULL
|
0
|
2026-05-22T11:17:53.108215+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448673108_m2.jpg...
|
PhpStorm
|
faVsco.js – UpdateSalesforceAccount.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779448673108_m2.jpg...
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
[{"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":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Http\\Controllers\\API;\n\nuse Carbon\\Carbon;\nuse ChaseConey\\LaravelDatadogHelper\\Datadog;\nuse Exception;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Illuminate\\Validation\\ValidationException;\nuse InvalidArgumentException;\nuse Jiminny\\Component\\ActivityAnalytics;\nuse Jiminny\\Component\\ActivitySearch;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionCollection;\nuse Jiminny\\Component\\PlaybackPage\\Comments\\Services\\ActivityCommentService;\nuse Jiminny\\Component\\Queue\\Constants;\nuse Jiminny\\Contracts\\ES\\Events\\UpdateSingleEntity;\nuse Jiminny\\Contracts\\ES\\UpdateTargetEnum;\nuse Jiminny\\Contracts\\Nudge\\NudgeFactoryInterface;\nuse Jiminny\\Contracts\\Playlist\\PlaylistTrackFactoryInterface;\nuse Jiminny\\Contracts\\Repositories\\PlaylistActivityRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ServiceInterface;\nuse Jiminny\\Enums\\TeamSetting;\nuse Jiminny\\Events\\Activities\\AiAutomation\\ActivityProspectAdded;\nuse Jiminny\\Events\\Activities\\Coaching\\Coached;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Http\\Controllers\\API\\BaseController as Controller;\nuse Jiminny\\Http\\Controllers\\CommentContextInterface;\nuse Jiminny\\Http\\Responses\\Api\\AbstractResponse;\nuse Jiminny\\Http\\Responses\\Api\\Response;\nuse Jiminny\\Http\\Serializers\\JsonSerializer;\nuse Jiminny\\Http\\Transformers\\ActivityCommentTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTopicTriggerTransformer;\nuse Jiminny\\Http\\Transformers\\ActivityTransformer;\nuse Jiminny\\Http\\Transformers\\AvailabilityNotificationTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingFeedbackTransformer;\nuse Jiminny\\Http\\Transformers\\CoachingSectionsTransformer;\nuse Jiminny\\Http\\Transformers\\SearchTransformer;\nuse Jiminny\\Http\\Transformers\\StatsTransformer;\nuse Jiminny\\Jobs\\Crm\\SaveActivity;\nuse Jiminny\\Jobs\\Crm\\UpdateStage;\nuse Jiminny\\Jobs\\Telephony\\StartRecording;\nuse Jiminny\\Jobs\\Telephony\\StopRecording;\nuse Jiminny\\Jobs\\Telephony\\ToggleRecording;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Activity\\CoachRequest;\nuse Jiminny\\Models\\Activity\\Comment;\nuse Jiminny\\Models\\Activity\\Search;\nuse Jiminny\\Models\\Activity\\SearchFilter;\nuse Jiminny\\Models\\Activity\\Share;\nuse Jiminny\\Models\\CoachingFeedback;\nuse Jiminny\\Models\\CoachingSection;\nuse Jiminny\\Models\\CoachingSectionCriterion;\nuse Jiminny\\Models\\CoachingSectionFeedback;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\LayoutEntity;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Nudge;\nuse Jiminny\\Models\\PlaybookCategory;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\CoachingFeedbackRepository;\nuse Jiminny\\Repositories\\ElasticActivityRepository;\nuse Jiminny\\Repositories\\TeamRepository;\nuse Jiminny\\Rules\\CrmReference;\nuse Jiminny\\Rules\\MultidimensionalArrayMaxCharRule;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\PlaybackService;\nuse Jiminny\\Services\\UserService;\nuse Jiminny\\VO\\Repository\\OnDemandActivitySearch\\Criteria;\nuse Psr\\Log\\LoggerInterface;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry;\nuse Symfony\\Component\\HttpFoundation;\n\nfinal class ActivityController extends Controller implements CommentContextInterface\n{\n // Number of minutes to look back on activities. i.e. a timeout on activity duration.\n private const int LOOK_BACK = 180;\n\n public function __construct(\n private ProviderRegistry $providerRegistry,\n private ActivityService $activityService,\n Response $response,\n private UserService $userService,\n private ActivitySearch\\Service\\ActivitySearch $activitySearch,\n private NudgeFactoryInterface $nudgeFactory,\n private ActivityCommentService $activityCommentService,\n private LoggerInterface $logger,\n private readonly CoachingFeedbackRepository $coachingFeedbackRepository,\n private readonly TeamRepository $teamRepository,\n ) {\n parent::__construct($response);\n }\n\n public static function getCommentImplementation(): string\n {\n return Comment::class;\n }\n\n public function delete()\n {\n $this->request->validate([\n '*' => 'uuid:activities',\n ]);\n\n $deletedIds = [];\n foreach ($this->request->all() as $activityId) {\n $activity = Activity::idOrUuId($activityId);\n\n try {\n if ($this->authorize('delete', $activity)) {\n $activity->delete();\n $deletedIds[] = $activityId;\n\n \\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n }\n } catch (AuthorizationException $authorizationException) {\n // They didn't have permission.\n }\n }\n\n return $this->response->withArray($deletedIds);\n }\n\n public function update(Request $request, Activity $activity)\n {\n $this->authorize('updateMetadata', $activity);\n\n $request->validate([\n 'title' => 'string|max:250',\n 'category_id' => 'uuid:playbook_categories',\n 'language' => [\n new In(\n LanguageDialect::query()\n ->with('language')\n ->cursor()\n ->map(static function (LanguageDialect $languageDialect): string {\n return $languageDialect->getLanguageLocale();\n })\n ->all()\n ),\n ],\n ]);\n\n if ($request->has('title')) {\n $activity->title = $request->input('title');\n }\n\n if ($request->has('category_id')) {\n $category = PlaybookCategory::uuid($request->input('category_id'));\n\n if ($category->playbook->team_id !== $request->user()->team_id) {\n return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n if ($request->has('language')) {\n if (! $activity->isInProgress()) {\n return $this->response->withError(\n 'Activity language can only be set while the meeting is in progress.',\n 400\n );\n }\n\n $activity->setLanguageCode($request->input('language'));\n }\n\n $activity->save();\n\n return $this->response->withOk();\n }\n\n // XXX: This should be merged with the update method.\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws SocialAccountTokenInvalidException\n *\n * @return mixed\n */\n public function summarize(Activity $activity): mixed\n {\n $this->logger->info('[Log Activity] Summarizing activity ', [\n 'activityId' => $activity->getUuid(),\n 'payload' => $this->request->all(),\n ]);\n $this->authorize('update', $activity);\n\n $this->logger->info('[Log Activity] Validating summary');\n // Validate the payload.\n $this->validateSummary($activity);\n\n // All objects must belong to this team.\n /** @var User $user */\n $user = $this->request->user();\n $team = $user->getTeam();\n $crmService = $this->providerRegistry->get($team->crm->provider);\n\n try {\n $crmUser = $user;\n if ($user->isCrmRequired() === false) {\n $crmUser = $team->owner;\n }\n $crmService->setUser($crmUser);\n } catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());\n }\n\n $rawEntities = $this->request->input('entities');\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid(\n $this->request->input('layout_id')\n );\n\n // Delay execution of CRM jobs to avoid locking issues.\n $jobDelay = 0;\n\n // If we have arrived from a notification, mark it as read.\n $notificationId = $this->request->input('nId');\n if ($notificationId) {\n $notification = $user->unreadNotifications->where('id', $notificationId)->first();\n\n if ($notification) {\n $notification->markAsRead();\n }\n }\n\n $title = $this->request->input('title');\n $prospects = $this->request->input('prospects');\n $opportunityId = $this->request->input('opportunity_id');\n $stageId = $this->request->input('stage_id');\n $categoryId = $this->request->input('category_id');\n $summary = $this->request->input('summary');\n $crmProviderId = $this->request->input('crm_id');\n $isInternal = $this->request->input('is_internal') ?? false;\n\n $lead = null;\n $category = null;\n $account = null;\n $contact = null;\n $opportunity = null;\n $stage = null;\n $callStage = null;\n\n foreach ($prospects as $prospectData) {\n $objectId = $prospectData['id'];\n\n if ($objectId === null) {\n continue;\n }\n\n $objectType = $prospectData['type'];\n $this->logger->info('debug', ['prospect_data' => $prospectData]);\n\n try {\n if ($objectType === null) {\n $this->logger->info('no object type');\n if ($crmService instanceof SupportsObjectTypeParseInterface) {\n $objectType = $crmService->parseObjectType($objectId);\n }\n }\n\n switch ($objectType) {\n case 'lead':\n $this->logger->info('Processing lead');\n /** @var Lead|null $lead */\n $lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();\n\n // Lead does not exist locally, import it.\n if ($lead === null) {\n $this->logger->info('Lead does not exist locally');\n /** @var Lead $lead */\n $lead = $crmService->syncLead($objectId);\n }\n\n $this->logger->info('Lead found', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n if ($stageId === null) {\n $this->logger->info('Stage ID is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $lead->stage;\n\n break;\n }\n\n $this->logger->info('Looking for stage');\n // Determine if they have changed the stage.\n /** @var Stage $stage */\n $stage = $team->crm->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_LEAD)\n ->firstOrFail();\n\n $this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);\n if ($lead->stage_id && $lead->stage_id !== $stage->id) {\n $this->logger->info('Stage has changed');\n // Storage current stage on activity.\n $callStage = $lead->stage;\n\n // The stage has changed, update in remote CRM.\n dispatch(new UpdateStage($activity, $lead, $callStage, $stage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing lead stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->getName(),\n $stage->getName()\n ),\n [\n 'user' => $user->getUuid(),\n 'lead' => $lead->getUuid(),\n ]\n );\n } else {\n $this->logger->info('Stage has not changed');\n // Stage remains as current.\n $callStage = $stage;\n }\n\n break;\n\n case 'account':\n $this->logger->info('Processing account');\n // If the object is not a lead, it should be an account.\n $account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();\n\n // Account does not exist locally, import it.\n if ($account === null) {\n $this->logger->info('Account does not exist locally');\n $account = $crmService->syncAccount($objectId);\n }\n\n $this->logger->info('Account found', ['accountId' => $account->id]);\n\n break;\n case 'contact':\n $this->logger->info('processing contact');\n $contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();\n\n // Contact does not exist locally, import it.\n if (! $contact instanceof Contact) {\n $this->logger->info('contact does not exist locally');\n $contact = $crmService->syncContact($objectId);\n }\n\n $this->logger->info('resolving account');\n $account = $this->resolveAccount($team, $contact, $crmService, $prospects);\n\n break;\n }\n\n // If they have specified an opportunity, retrieve this with stage.\n if ($opportunityId) {\n $this->logger->info('opportunity id is set');\n $opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();\n\n // Opportunity does not exist locally, import it.\n if ($opportunity === null) {\n $this->logger->info('opportunity does not exist locally');\n $opportunity = $crmService->syncOpportunity($opportunityId);\n }\n\n if ($stageId === null) {\n $this->logger->info('stage id is null');\n // If it was not provided, just assume it is the current stage.\n $callStage = $opportunity->stage ?? null;\n } else {\n $this->logger->info('looking for stage');\n /** @var ?Stage $opportunityStage */\n $opportunityStage = $team->crm\n ->stages()\n ->uuid($stageId, false)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n // There is a chance we still cannot import this opportunity.\n if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {\n $this->logger->info('opportunity stage has changed');\n // Storage current stage on activity.\n $callStage = $opportunity->stage;\n\n dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));\n\n $this->logger->info(\n sprintf(\n '[%s] User changing opportunity stage from %s to %s',\n $crmService->getDisplayName(),\n $callStage->name,\n $opportunityStage->name\n ),\n [\n 'userId' => $user->id_string,\n 'opportunityId' => $opportunity->id_string,\n ]\n );\n } else {\n $this->logger->info('opportunity stage has not changed');\n // Stage remains as current.\n $callStage = $opportunityStage;\n }\n }\n }\n\n if ($crmProviderId) {\n // Cast $crmProviderId to string otherwise it won't use database index for some records\n $linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();\n\n // Check if this activity has already been assigned to a different activity.\n if ($linkedActivity && $linkedActivity->id !== $activity->id) {\n throw new InvalidArgumentException(\n 'Sorry, the linked task has already been logged under a different call. '\n . 'Please choose another linked task.'\n );\n }\n }\n } catch (InvalidArgumentException $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorWrongArgs($exception->getMessage());\n } catch (Exception $exception) {\n $this->logger->error('Failed to process prospect', [\n 'prospect_data' => $prospectData,\n 'reason' => $exception->getMessage(),\n ]);\n\n // Return a JSON response with the response array and status code.\n return $this->response->errorInternalError(\n 'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'\n );\n }\n }\n\n if ($categoryId) {\n $category = PlaybookCategory::uuid($categoryId);\n\n if ($category->playbook->team_id !== $team->id) {\n throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');\n }\n\n $activity->playbook_category_id = $category->id;\n }\n\n $this->logger->info('Prospect data', [\n 'lead_id' => $lead?->getId(),\n 'account_id' => $account?->getId(),\n 'contact_id' => $contact?->getId(),\n 'opportunity_id' => $opportunity?->getId(),\n 'stage_id' => $stage?->getId(),\n ]);\n\n if ($title) {\n $activity->title = $title;\n }\n\n if ($summary) {\n $activity->summary = $summary;\n }\n\n if ($crmProviderId) {\n $activity->crm_provider_id = $crmProviderId;\n }\n\n if ($callStage) {\n $this->logger->info('Setting stage id', ['stageId' => $callStage->id]);\n $activity->stage_id = $callStage->id;\n }\n\n if ($lead) {\n $this->logger->info('Setting lead id', ['leadId' => $lead->id]);\n $activity->lead_id = $lead->id;\n\n // If we are changed from an account > lead, unset the account data.\n $this->logger->info('Unsetting account id, opportunity id, contact id, value');\n $activity->account_id = null;\n $activity->opportunity_id = null;\n $activity->contact_id = null;\n $activity->value = null;\n }\n\n if ($account) {\n $this->logger->info('Setting account id', ['accountId' => $account->id]);\n $activity->account_id = $account->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('unsetting lead id');\n $activity->lead_id = null;\n\n // Unset the contact if switching different accounts. Will be set up below if still applicable.\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {\n $this->logger->info('Unsetting contact id');\n $activity->contact_id = null;\n }\n }\n\n if ($opportunity) {\n $this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);\n $this->logger->info('unsetting lead id');\n $activity->opportunity_id = $opportunity->id;\n $activity->value = $opportunity->value;\n\n // If we are changed from an lead > account, unset the lead data.\n $activity->lead_id = null;\n }\n\n if ($contact) {\n $this->logger->info('setting contact id', ['contactId' => $contact->id]);\n $activity->contact_id = $contact->id;\n\n // If we are changed from an lead > account, unset the lead data.\n $this->logger->info('Unsetting lead id');\n $activity->lead_id = null;\n }\n\n $activity->is_internal = $isInternal;\n $activity->save();\n $activity->refresh();\n\n $this->logger->notice('Activity saved', [\n 'activity_id' => $activity->getId(),\n 'lead_id' => $activity->lead_id,\n 'account_id' => $activity->account_id,\n 'contact_id' => $activity->contact_id,\n 'opportunity_id' => $activity->opportunity_id,\n 'stage_id' => $activity->stage_id,\n 'crm_provider_id' => $activity->getCrmProviderId(),\n ]);\n\n // Store entities as field data on the activity.\n $updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);\n\n if ($activity->isLoggable()) {\n // Follow-up Task or Event data.\n $followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);\n\n $this->logger->info('CRM LOG manual log triggered', [\n 'activityId' => $activity->getUuid(),\n 'followupData' => $followupData,\n 'userId' => $user->getUuid(),\n ]);\n\n // Store data in the CRM.\n // ++add check for crm_required\n $job = new SaveActivity($activity, $followupData);\n\n if ($updatedData) {\n $job->delay(Carbon::now()->addMinutes($jobDelay));\n }\n\n dispatch($job);\n\n // Manually dispatch log for Opportunity or Prospect added\n if ($activity->hasOpportunity() || $activity->hasProspect()) {\n event(new ActivityProspectAdded(\n activity: $activity,\n eventSource: 'manually-log-crm-data'\n ));\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.\n *\n * @param ServiceInterface $service\n * @param Activity $activity\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array\n {\n $updatedData = [];\n $existingData = $activity->data()->get();\n\n // We need to delete any existing data to overwrite with latest values.\n $activity->data()->delete();\n\n $layoutEntities = $layout->entities()\n ->with('field', 'parent')\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->get();\n\n /** @var LayoutEntity $entity */\n foreach ($layoutEntities as $entity) {\n // If the user has provided a value for this entity\n if (array_key_exists($entity->id_string, $entities)) {\n $value = $entities[$entity->id_string];\n\n // Convert raw data into values that the CRM can consume.\n if ($value) {\n $value = $service->normalizeValue($entity->field->type, $value);\n }\n\n // Check the field is part of the activity-summary section.\n if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {\n // This is the internal database ID, not the external CRM ID.\n $objectId = null;\n\n switch ($entity->field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $objectId = $activity->account_id;\n\n break;\n\n case Field::OBJECT_CONTACT:\n $objectId = $activity->contact_id;\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $objectId = $activity->opportunity_id;\n\n break;\n\n case Field::OBJECT_LEAD:\n $objectId = $activity->lead_id;\n\n break;\n\n case Field::OBJECT_TASK:\n case Field::OBJECT_EVENT:\n $objectId = $activity->id;\n\n break;\n }\n\n if ($objectId) {\n /** @var FieldData $data */\n $data = $activity->data()->create([\n 'crm_layout_entity_id' => $entity->id,\n 'crm_field_id' => $entity->crm_field_id,\n 'object_type' => $entity->field->object_type,\n 'object_id' => $objectId,\n 'value' => $value,\n ]);\n\n // Never send read-only field data to the CRM.\n if ($entity->read_only === false && $entity->is_visible) {\n $existingValue = $existingData\n ->where('crm_layout_entity_id', $entity->id)\n ->where('crm_field_id', $entity->crm_field_id)\n ->where('object_type', $entity->field->object_type)\n ->where('object_id', $objectId)\n ->first();\n\n // If the field was actually changed, we need to reflect this in the CRM too.\n if ($existingValue === null || $existingValue->value !== $value) {\n $updatedData[] = $data->id;\n }\n }\n }\n }\n }\n }\n\n return $updatedData;\n }\n\n /**\n * Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.\n *\n * @param ServiceInterface $crmService\n * @param Layout $layout\n * @param array $entities The raw entity data from user\n *\n * @return array\n */\n private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array\n {\n $fieldData = [];\n foreach ($entities as $entityId => $value) {\n // Only bother with fields that have a value.\n if ($value) {\n // Extract the entity from the UUID. Check the field is valid and part of the follow-up section.\n $entity = $layout->entities()\n ->uuid($entityId, false)\n ->whereHas('parent', function ($query) {\n $query->where('label', 'follow-up');\n })\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->first();\n\n if ($entity) {\n // Convert raw data into values that the CRM can consume.\n $value = $crmService->normalizeValue($entity->field->type, $value);\n\n // Add the field and value to the payload.\n $fieldData += [\n $entity->field->crm_provider_id => $value,\n ];\n }\n }\n }\n\n return $fieldData;\n }\n\n /**\n * @param Activity $activity\n */\n private function validateSummary(Activity $activity): void\n {\n $team = $activity->user->team;\n $crmProvider = $team->crm->provider;\n $attributes = [];\n\n $rules = [\n 'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,\n 'title' => 'string|max:250',\n 'prospects' => 'required|array',\n 'opportunity_id' => new CrmReference($crmProvider),\n 'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',\n 'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator\n 'summary' => 'max:50000',\n 'nId' => 'exists:notifications,id',\n 'crm_id' => new CrmReference($crmProvider),\n 'entities' => 'array',\n 'is_internal' => 'boolean',\n ];\n\n /** @var Layout $layout */\n $layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));\n\n // Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.\n $entities = $layout->entities()\n ->where('read_only', 0)\n ->whereHas('field', function ($query) {\n $query->where('is_selectable', 1);\n })\n ->whereHas('parent', function ($query) use ($activity) {\n if ($activity->isLoggable() === false) {\n $query->where('label', '<>', 'follow-up');\n }\n });\n\n $isInternal = $this->request->input('is_internal', false);\n\n foreach ($entities->get() as $entity) {\n $rules += $this->buildFieldValidator($entity, $isInternal);\n $attributes += $this->buildFieldMessage($entity);\n }\n\n $this->request->validate($rules, [], $attributes);\n }\n\n private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array\n {\n return [\n 'entities.' . $entity->id_string => $entity->getValidator($isInternal),\n ];\n }\n\n /**\n * @param LayoutEntity $entity\n *\n * @return array\n */\n private function buildFieldMessage(LayoutEntity $entity): array\n {\n $label = $entity->label;\n if ($label === null) {\n $label = $entity->field->label;\n }\n\n return [\n 'entities.' . $entity->id_string => $label,\n ];\n }\n\n public function search(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->debugLog(\n $user,\n 'User extracted from request',\n ['user' => $user->getId(), 'tz' => $user->getTimezone()]\n );\n\n $searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());\n\n $this->debugLog(\n $user,\n 'ActivitySearch criteria built',\n ['searchCriteria' => $searchCriteria]\n );\n\n $filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);\n\n $this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);\n\n $this->validateSearch($request, $filterSet);\n\n $this->debugLog($user, 'Request validated');\n\n $searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);\n\n /** @var Collection<Activity> $activities */\n $activities = $searchResponse['results'];\n\n $this->debugLog($user, 'Activities ES response extracted');\n\n $hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(\n $user->getTeamId(),\n TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),\n );\n\n if ($hideInternalMeetingsSetting?->getValue() === '1') {\n $activities = $activities->filter(function (Activity $activity) {\n if ($activity->is_internal && empty($activity->actual_start_time)) {\n return false;\n }\n\n return true;\n });\n }\n\n $this->debugLog($user, 'Internal meetings (?!) filtered');\n\n $this->response->getManager()\n ->parseIncludes([\n 'category',\n 'organizer.group',\n 'prospect',\n 'stage',\n 'opportunity',\n 'stats',\n 'scorecards',\n 'masterTrack',\n 'activeParticipants',\n 'notification',\n ])\n ->setSerializer(new JsonSerializer());\n\n $transformerExcludes = $this->request->input('exclude');\n if ($transformerExcludes) {\n $this->response->getManager()->parseExcludes($transformerExcludes);\n }\n\n $this->debugLog($user, 'Response Manager (?!) applied');\n\n $transformer = new ActivityTransformer();\n $transformer->setConsumer($user);\n\n $this->debugLog($user, 'Activity Transformer added');\n\n $resource = new \\League\\Fractal\\Resource\\Collection($activities, $transformer);\n $page = $searchCriteria->getPageNumber();\n\n $this->debugLog($user, 'Search criteria page number called', ['page' => $page]);\n\n $histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');\n\n $this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);\n\n return $this->response->withArray([\n 'pagination' => [\n 'total' => $searchResponse['totalHits'],\n 'current' => $page,\n 'prev' => max($page - 1, 1),\n 'next' => $page + 1,\n ],\n 'results' => $this->response->getManager()->createData($resource)->toArray(),\n 'histogram' => $histogram,\n ]);\n }\n\n private function debugLog(User $user, string $logMessage, ?array $context = []): void\n {\n // Debug for Learning People Only\n if ($user->getTeamId() !== 260) {\n return;\n }\n\n Log::notice(\n sprintf('[activity-search-controller] %s', $logMessage),\n $context\n );\n }\n\n /** @throws ValidationException */\n private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void\n {\n $rules = [\n 'exclude' => 'array',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ];\n\n if ($prefix !== null && mb_strpos($prefix, '.') !== false) {\n $rules[rtrim($prefix, '.')] = sprintf(\n 'required|array|max:%d',\n $filterSet->count()\n );\n }\n\n $validationRules = $filterSet->getValidationRules($prefix)\n ->merge($rules)\n ->all();\n\n $request->validate($validationRules);\n }\n\n public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $search = $this->updateOrCreateActivitySearch($request);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function updateActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('update', $search);\n\n $this->updateOrCreateActivitySearch($request, $search);\n\n return $this->response->withOk();\n }\n\n private function storeNamedSearchFilters(\n Collection $request,\n Search $search,\n FilterDefinitionCollection $filterSet,\n ?string $prefix = null,\n ): self {\n $arrayTypeProperties = $filterSet\n ->getPropertyTypes([\n FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,\n ])\n ->all();\n\n $supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);\n\n foreach ($supportedRequestProperties as $requestPropertyName) {\n if (! array_has($request, $requestPropertyName)) {\n continue;\n }\n\n /** @var string|string[] $propertyValue */\n $propertyValue = array_get($request, $requestPropertyName);\n $propertyName = $prefix === null\n ? $requestPropertyName\n : mb_substr($requestPropertyName, mb_strlen($prefix));\n\n $isArrayType = array_has($arrayTypeProperties, $propertyName);\n\n if (! $isArrayType) {\n /** @var string $requestPropertyValue */\n\n $search->filters()->updateOrCreate(\n [\n 'filter' => $propertyName,\n ],\n [\n 'value' => $propertyValue,\n ]\n );\n\n continue;\n }\n\n /** @var string[] $requestPropertyValue */\n\n /** @var SearchFilter[]|Collection $existingFilterValues */\n $existingFilterValuesKeyed = $search->filters()\n ->where('filter', $propertyName)\n ->get()\n ->keyBy('id');\n\n // Iterate over values provided as request parameters\n foreach ($propertyValue as $value) {\n /** @var SearchFilter|null $valueFilter */\n $valueFilter = $search->filters()\n ->where(\n [\n 'filter' => $propertyName,\n 'value' => $value,\n ]\n )\n ->first();\n\n if ($valueFilter !== null) {\n // Remove filter value pair from list to be deleted\n $existingFilterValuesKeyed->forget($valueFilter->id);\n } else {\n // Add new filter/value pair\n $search->filters()->updateOrCreate([\n 'filter' => $propertyName,\n 'value' => $value,\n ]);\n }\n }\n\n // Delete filter value pairs for this filter that no longer exist in request parameters\n foreach ($existingFilterValuesKeyed as $existingFilter) {\n $existingFilter->delete();\n }\n }\n\n /** @var Collection<int, SearchFilter> $filtersKeyed */\n $filtersKeyed = $search->filters()->get()->keyBy('filter');\n\n // wipe removed filters from this search\n foreach ($filtersKeyed as $filterName => $filter) {\n if (array_has($request, $prefix . $filterName)) {\n continue;\n }\n\n // Remove all filter values for this filter\n $search->filters()->where('filter', $filterName)->delete();\n }\n\n return $this;\n }\n\n /**\n * @throws AuthorizationException\n */\n public function fetchActivitySearch(\n Search $search,\n Request $request,\n SearchTransformer $searchTransformer,\n ): JsonResponse {\n $this->authorize('view', $search);\n\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $search->loadCount('automatedReports');\n\n return $this->response->withItem(\n $search,\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse\n {\n /** @var User $user */\n $user = $request->user();\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection(\n $user->searches()->withCount('automatedReports')->get(),\n $searchTransformer\n ->withConsumer($user)\n );\n }\n\n /**\n * Deletes a saved search\n *\n * @param Request $request\n * @param Search $search\n *\n * @throws Exception\n *\n * @return JsonResponse\n */\n public function deleteActivitySearch(Request $request, Search $search): JsonResponse\n {\n $this->authorize('delete', $search);\n\n // Disable and orphan any AutomatedReports that use this search\n $search->automatedReports()->withTrashed()->update([\n 'activity_search_id' => null,\n 'status' => false,\n ]);\n\n // Delete filters and the search itself\n $search->filters()->delete();\n $search->delete();\n\n return $this->response->withOk();\n }\n\n public function live(Request $request, ElasticActivityRepository $repository): JsonResponse\n {\n $user = $this->getUserFromRequest($request);\n\n $this->request->validate([\n 'sort_direction' => 'in:asc,desc',\n 'limit' => 'integer|min:1|max:50',\n 'page' => 'integer|min:1',\n ]);\n\n $activities = $repository->getLiveCoachingEligibleActivities(\n user: $user,\n lookBackMinutes: self::LOOK_BACK,\n limit: (int) $this->request->input('limit', 25),\n page: (int) $this->request->input('page', 1),\n sortBy: ['actual_start_time', 'scheduled_start_time'],\n sortDirection: (string) $this->request->input('sort_direction', 'asc'),\n );\n\n $this->response\n ->getManager()\n ->parseIncludes(['organizer.group', 'prospect'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($activities, new ActivityTransformer());\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function show(Activity $activity, ActivityService $activityService): JsonResponse\n {\n $this->authorize('show', $activity);\n\n $user = $activity->getUser();\n $team = $user->getTeam();\n\n // Sync the opportunity with the latest data if possible.\n if ($activity->opportunity_id) {\n try {\n $integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n\n $crmService->syncOpportunity($activity->opportunity->crm_provider_id);\n } catch (Exception $exception) {\n // Move on.\n }\n }\n\n $activityData = $activityService->getActivityData($this->request->user(), $activity);\n\n return response()->json($activityData);\n }\n\n public function createRecording(Activity $activity)\n {\n $this->authorize('record', $activity);\n\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Tell Twilio to start recording this activity.\n if ($activity->recording_state === Activity::RECORDING_OFF) {\n $job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withCreated();\n }\n\n return $this->response->errorGone('Activity is already recording.');\n }\n\n public function updateRecording(Request $request, Activity $activity)\n {\n $this->authorize('record', $activity);\n\n $request->validate([\n 'preference' => 'boolean',\n 'state' => [\n 'string',\n Rule::in([\n Activity::RECORDING_IN_PROGRESS,\n Activity::RECORDING_PAUSED,\n ]),\n ],\n ]);\n\n if ($request->has('state')) {\n if ($activity->hasRecordingReasonComplianceRestricted()) {\n return $this->response->errorGone('Recording this number has been disabled by your organization.');\n }\n\n // Toggle the recording state between paused and resumed.\n if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {\n $job = (new ToggleRecording($activity, $request->input('state')))\n ->onQueue(Constants::QUEUE_CONFERENCES);\n\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Recording is not toggleable.');\n }\n\n if ($request->has('preference')) {\n $activity->update([\n 'recording_preference' => $request->input('preference') ? 1 : 0,\n ]);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorWrongArgs('Something went wrong');\n }\n\n public function stopRecording(Activity $activity)\n {\n $this->authorize('stopRecord', $activity);\n\n // Tell Twilio to stop recording this activity.\n if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {\n $job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);\n dispatch($job);\n\n return $this->response->withOk();\n }\n\n return $this->response->errorGone('Activity is not recording.');\n }\n\n /**\n * Add activity to this user's favorites playlist\n *\n * @throws AuthorizationException\n */\n public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse\n {\n $this->authorize('favorite', $activity);\n\n $user = $this->getUserFromRequest($this->request);\n $favorite = $activity->wasFavoritedBy($user);\n $name = $activity->activity_title ?? '';\n\n // It needs to check at least one record.\n if (! $favorite) {\n $favoritePlaylist = $user->favoritePlaylist();\n\n $playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(\n $activity,\n $user,\n $favoritePlaylist\n );\n\n if ($playlistActivity !== null) {\n $playlistActivity->update(\n // Just update, don't sort.\n ['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],\n );\n } else {\n $playlistActivity = $activity->playlistActivities()->create([\n 'playlist_id' => $favoritePlaylist->getId(),\n 'user_id' => $user->getId(),\n 'start_time' => 0,\n 'name' => mb_strimwidth($name, 0, 100),\n ]);\n // Sort it on top.\n $playlistActivity->update(\n [\n 'sort' => $playlistActivityRepository->calculateNewSortOrder(\n null,\n $playlistActivity,\n ),\n ],\n );\n }\n\n $playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);\n\n return new JsonResponse([], JsonResponse::HTTP_CREATED);\n }\n\n return new JsonResponse(\n [\n 'error' => [\n 'code' => AbstractResponse::CODE_CONFLICT,\n 'http_code' => JsonResponse::HTTP_CONFLICT,\n 'message' => 'Resource Already Exists',\n ],\n ],\n JsonResponse::HTTP_CONFLICT,\n );\n }\n\n /**\n * Remove activity from this user's favorites playlist\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unfavorite(Activity $activity)\n {\n $user = $this->request->user();\n\n $favorites = $activity->favoritedBy($user);\n\n if ($favorites && $favorites->isEmpty()) {\n return $this->response->errorNotFound('Favorite not found.');\n }\n\n $this->authorize('unfavorite', [$activity, $favorites]);\n\n // When you unfavorite an activity,\n // it should remove all the activities in it, including snippets.\n $isDeleted = $favorites->each(function ($favorite) {\n $favorite->forceDelete();\n });\n\n if ($isDeleted) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not remove favorite.');\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function notify(Activity $activity)\n {\n $this->authorize('notify', $activity);\n\n $user = $this->request->user();\n\n $existingNotification = $activity->availabilityNotifications()\n ->where('user_id', $user->id)\n ->exists();\n\n if ($existingNotification) {\n return $this->response->errorWrongArgs('Notification is already configured.');\n }\n\n $notification = Activity\\AvailabilityNotification::create([\n 'user_id' => $user->id,\n 'activity_id' => $activity->id,\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($notification, new AvailabilityNotificationTransformer());\n }\n\n /**\n * @param Activity $activity\n * @param Activity\\AvailabilityNotification $notification\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function unnotify(Activity $activity, Activity\\AvailabilityNotification $notification)\n {\n $this->authorize('unnotify', [$activity, $notification]);\n\n if ($notification->sent_at || $notification->delete()) {\n return $this->response->withNoContent();\n }\n\n return $this->response->errorGone('Could not delete notification.');\n }\n\n public function play(Request $request, Activity $activity)\n {\n $this->authorize('stream', $activity);\n\n $request->validate([\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $activity->plays()->create([\n 'user_id' => $user->getId(),\n 'start_time' => $request->input('start_time'),\n ]);\n\n return $this->response->withCreated();\n }\n\n /**\n * @param Activity $activity\n *\n * @return mixed\n */\n public function comment(Activity $activity)\n {\n return $this->newComment($activity);\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @return mixed\n */\n public function replyComment(Activity $activity, Comment $comment)\n {\n return $this->newComment($activity, $comment);\n }\n\n /**\n * @param Activity $activity\n * @param Comment|null $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n protected function newComment(Activity $activity, ?Comment $comment = null)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n 'type' => 'integer|between:0,3',\n 'visibility' => sprintf('nullable|integer|between:1,%d', count(Comment::getVisibilityLevels())),\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n $threadStartId = null;\n if ($comment) {\n $threadStartId = $comment->thread_start_id ?: $comment->id;\n }\n\n try {\n $newComment = Comment::create([\n 'parent_comment_id' => $comment->id ?? null,\n 'thread_start_id' => $threadStartId,\n 'activity_id' => $activity->id,\n 'user_id' => $this->request->user()->id,\n 'comment' => trim($this->request->input('comment')),\n 'start_time' => $this->request->input('start_time', 0),\n 'end_time' => $this->request->input('end_time', 0),\n 'type' => $this->request->input('type', Comment::TYPE_NEUTRAL),\n 'visibility' => $this->request->input('visibility', Comment::VISIBILITY_PUBLIC),\n ]);\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($newComment, new ActivityCommentTransformer());\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not create comment.' . $exception->getMessage());\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function updateComment(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'comment' => 'required|between:1,5000',\n //'start_time' => 'numeric|between:0,'.$activity->duration,\n //'end_time' => 'required_with:start_time|greater_than_or_equal:start_time|numeric|between:0,'.$activity->duration,\n ]);\n\n try {\n $comment->update([\n 'comment' => trim($this->request->input('comment')),\n ]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment.');\n }\n }\n\n public function updateCommentVisibility(Activity $activity, Comment $comment)\n {\n $this->authorize('comment', [$activity, $comment]);\n\n $this->request->validate([\n 'visibility' => sprintf('integer|between:1,%d', count(Comment::getVisibilityLevels())),\n ]);\n\n $visibility = $this->request->input('visibility');\n\n if ($comment->parent !== null) {\n return $this->response->errorWrongArgs('Comment visibility can only be updated on top level comments.');\n }\n\n try {\n $this->activityCommentService->updateCommentVisibility($comment, $visibility);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withOk();\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n return $this->response->errorInternalError('Could not update comment\\'s visibility.');\n }\n }\n\n /**\n * @param Activity $activity\n * @param Comment $comment\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function deleteComment(Activity $activity, Comment $comment)\n {\n $this->authorize('deleteComment', [$activity, $comment]);\n\n // Delete comment and any children.\n $comment->delete();\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function fetchComments()\n {\n $user = $this->request->user();\n $this->request->validate([\n 'forUserId' => 'uuid:users,team_id,' . $user->team_id,\n 'types' => 'array',\n 'types.*' => 'integer|between:0,3',\n ]);\n $forUser = null;\n\n $types = [Comment::TYPE_NEUTRAL, Comment::TYPE_GAME_CHANGER, Comment::TYPE_POSITIVE];\n $user = $this->request->user();\n if ($this->request->has('forUserId')) {\n $forUser = $user->team->users()->uuid($this->request->input('forUserId'));\n }\n\n $comments = Comment::query()\n ->whereHas('activity', static function (Builder $builder) use ($user, $forUser): void {\n $builder\n // I left feedback on my own activity; or\n ->where('activities.user_id', $user->getId());\n if ($forUser) {\n // I left feedback on any activity for this user.\n $builder->orWhere([\n 'user_id' => $user->getId(),\n 'activities.user_id' => $forUser->getId(),\n ]);\n }\n })\n ->whereIn('type', $this->request->input('types', $types))\n ->orderBy('created_at', 'desc')\n ->get();\n\n $this->response\n ->getManager()\n ->parseIncludes(['activity', 'user'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($comments, new ActivityCommentTransformer());\n }\n\n public function deleteCoachingFeedback(Activity $activity, CoachingFeedback $coachingFeedback)\n {\n $this->authorize('deleteCoachingFeedback', [$activity, $coachingFeedback]);\n $activity = $coachingFeedback->getActivity();\n\n if ($coachingFeedback->delete()) {\n event(new UpdateSingleEntity(\n entityId: $activity->getId(),\n updateTarget: UpdateTargetEnum::ACTIVITY,\n purpose: 'delete-coaching-feedback',\n ));\n\n return $this->response->withOk();\n }\n\n return $this->response->withError('Delete operation failed. Contact support.', 500);\n }\n\n /**\n * Add new or update Coaching feedback\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n * @throws \\Illuminate\\Validation\\ValidationException\n *\n * @return mixed\n */\n public function putCoachingFeedback(Request $request, Activity $activity)\n {\n $user = $request->user();\n\n if (! $user instanceof User) {\n abort(403);\n }\n $teamId = $user->getTeamId();\n\n $this->authorize('coach', $activity);\n\n $this->request->validate([\n 'coach_id' => 'required|uuid:users,team_id,' . $teamId,\n 'coachee_id' => 'required|uuid:users,team_id,' . $teamId,\n 'visibility' => ['required', Rule::in(CoachingFeedback::VISIBILITIES)],\n 'coaching_sections.*.uuid' => 'required|uuid:coaching_sections',\n 'coaching_sections.*.score' => ['required', Rule::in(CoachingSectionFeedback::SCORES)],\n 'coaching_sections.*.summary' => 'string|max:10000',\n 'coaching_sections.*.criteria.*.uuid' => 'required|uuid:coaching_section_criteria',\n 'coaching_sections.*.criteria.*.note' => 'required|string|max:10000',\n 'sharedWithUsers' => [\n 'required_if:visibility,' . CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS,\n 'array',\n ],\n 'sharedWithUsers.*' => [\n 'uuid:users,team_id,' . $teamId,\n ],\n ]);\n\n /** @var User $coach */\n $coach = User::uuid($this->request->input('coach_id'));\n /** @var User $coachee */\n $coachee = User::uuid($this->request->input('coachee_id'));\n $coachingSectionFeedbacks = $this->request->input('coaching_sections');\n\n $previousRecord = $this->coachingFeedbackRepository->getOneForActivityByCoacheeAndCoach(\n $coachee->getId(),\n $coach->getId(),\n $activity->getId()\n );\n $recordIsNew = false;\n if ($previousRecord === null) {\n $recordIsNew = true;\n }\n\n if (! $coachee->isSameTeamId($coach)) {\n return $this->response->errorForbidden('User not member of your team.');\n }\n\n if (! is_array($coachingSectionFeedbacks) || count($coachingSectionFeedbacks) < 1) {\n return $this->response->withError('At least one Coaching Framework Section shall be scored.', 422);\n }\n\n if (! $activity->participants()->where('participants.user_id', $coachee->id)->exists()) {\n return $this->response->withError('Coached user did not participate activity.', 422);\n }\n\n $visibility = $this->request->input('visibility');\n\n $shouldSendNotification = $recordIsNew;\n if ($recordIsNew === false && $visibility !== $previousRecord->getVisibility()) {\n $shouldSendNotification = true;\n }\n\n /**\n * Create CoachingFeedback\n *\n * @var CoachingFeedback $coachingFeedback\n */\n $coachingFeedback = $activity->coachingFeedbacks()->updateOrCreate(\n [\n 'coach_id' => $coach->id,\n 'coachee_id' => $coachee->id,\n ],\n [\n 'framework_id' => $activity->category->id,\n 'visibility' => $visibility,\n ]\n );\n\n $sharedUserIds = [];\n if ($visibility === CoachingFeedback::VISIBLE_TO_SPECIFIC_USERS) {\n foreach ($this->request->input('sharedWithUsers') as $sharedWithUserUuid) {\n /** @var User $user */\n $user = User::uuid($sharedWithUserUuid);\n $sharedUserIds[] = $user->getId();\n }\n }\n\n $syncResult = $coachingFeedback->customAccessUsers()->sync($sharedUserIds);\n\n $scores = [];\n\n\n /**\n * Create CoachingSectionsFeedbacks.\n *\n * @var CoachingSectionFeedback $coachingSectionFeedback\n */\n foreach ($coachingSectionFeedbacks as $coachingSectionFeedbackInput) {\n $coachingSection = CoachingSection::uuid($coachingSectionFeedbackInput['uuid']);\n $coachingSectionFeedback = $coachingFeedback->sectionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_id' => $coachingSection->id,\n ],\n [\n 'score' => array_get($coachingSectionFeedbackInput, 'score'),\n 'summary' => array_get($coachingSectionFeedbackInput, 'summary') ?? '',\n ]\n );\n\n $scores[] = array_get($coachingSectionFeedbackInput, 'score');\n\n $criteria = array_get($coachingSectionFeedbackInput, 'criteria');\n if (is_array($criteria) && ! empty($criteria)) {\n foreach ($criteria as $criteriaFeedbackInput) {\n $coachingSectionFeedback->criterionFeedbacks()->updateOrCreate(\n [\n 'coaching_section_criterion_id' => CoachingSectionCriterion::uuid(array_get($criteriaFeedbackInput, 'uuid'))\n ->id,\n ],\n ['note' => array_get($criteriaFeedbackInput, 'note')],\n );\n }\n }\n }\n\n $coachingFeedback->average_score = array_sum($scores) / count($scores);\n\n if ($recordIsNew === false && $coachingFeedback->getAverageScore() !== $previousRecord->getAverageScore()) {\n $shouldSendNotification = true;\n }\n if (! empty($syncResult['attached']) || ! empty($syncResult['detached']) || ! empty($syncResult['updated'])) {\n $shouldSendNotification = true;\n }\n\n $coachingFeedback->save();\n // ensure updated at for coaching feedback on section feedback summary added.\n $coachingFeedback->touch();\n\n if ($shouldSendNotification) {\n event(new Coached($coachingFeedback));\n }\n\n Datadog::increment('jiminny.activity.score.update', 1, ['company' => $activity->user->team->slug]);\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n $coachingFeedbackTransformer = new CoachingFeedbackTransformer();\n $coachingFeedbackTransformer->setConsumer($this->getUserFromRequest($request));\n\n return $this->response->withItem($coachingFeedback, $coachingFeedbackTransformer);\n }\n\n\n /**\n * Retrieve category criteria for coaching.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachingSections(Activity $activity)\n {\n $this->authorize('coach', $activity);\n\n if ($activity->category === null) {\n return $this->response->errorUnprocessable('Category has not yet been assigned.');\n }\n\n $criteria = $activity\n ->category\n ->coachingSections()\n ->where('is_enabled', 1)\n ->orderBy('sequence', 'asc');\n\n $this->response\n ->getManager()\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withCollection($criteria->get(), new CoachingSectionsTransformer());\n }\n\n /**\n * @throws AuthorizationException\n * @throws ValidationException\n *\n * @return mixed\n */\n public function addToPlaylist(Activity $activity, PlaylistTrackFactoryInterface $playlistTrackFactory)\n {\n $this->request->validate([\n 'playlists' => 'required|array',\n 'playlists.*' => 'uuid:playlists',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'name' => 'required|max:100',\n ]);\n\n $this->authorize('addToPlaylist', [$activity, $this->request->input('playlists')]);\n\n $startTime = $this->request->input('start_time');\n $endTime = $this->request->input('end_time');\n $name = $this->request->input('name');\n /** @var User $user */\n $user = $this->request->user();\n\n // Get playlist by uuid.\n foreach ($this->request->input('playlists') as $playlistId) {\n // Pull out the playlist model.\n $playlist = Playlist::uuid($playlistId);\n\n $playlistTrackFactory->createTrack($playlist, $user, [\n 'name' => $name,\n 'activity' => $activity,\n 'start_time' => $startTime,\n 'end_time' => $endTime,\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function share(Request $request, Activity $activity): JsonResponse\n {\n $this->authorize('share', $activity);\n\n $request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'recipients.*.type' => 'in:user,group',\n 'recipients.*.id' => 'string|max:40',\n 'share' => 'string|max:255',\n ]);\n\n $user = $request->user();\n\n $recipients = $request->get('recipients');\n $users = $this->userService->convertRecipientsToUsers($user, $recipients);\n\n $shareData = [\n 'from_user_id' => $user->id,\n 'note' => $request->input('note'),\n 'start_time' => $request->input('start_time'),\n 'end_time' => $request->input('end_time'),\n ];\n\n // Create a share object against a notification provider channel\n if ($request->input('share')) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'notification_provider_channel' => $request->input('share'),\n ]\n )\n );\n\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n\n // Create a share object against each recipient\n foreach ($users as $recipient) {\n /** @var Share $share */\n $share = $activity->shares()->create(\n array_merge(\n $shareData,\n [\n 'to_user_id' => $recipient->id,\n ]\n )\n );\n\n // If parent_share_id has been selected yet\n if (! isset($shareData['parent_share_id'])) {\n // All subsequent shares need to reference this row as parent_share_id\n $shareData['parent_share_id'] = $share->id;\n }\n }\n\n return $this->response->withOk();\n }\n\n /**\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function coachRequest(Activity $activity)\n {\n $this->authorize('coachRequest', $activity);\n\n $this->request->validate([\n 'note' => 'string|max:1000',\n 'start_time' => 'numeric|between:0,' . $activity->duration,\n 'end_time' => 'nullable|greater_than_or_equal:start_time|numeric|between:0,' . $activity->duration,\n 'coachers.*.type' => 'required|in:user',\n 'coachers.*.id' => 'required',\n ]);\n\n $coachers = $this->request->get('coachers');\n $user = $this->request->user();\n $users = $this->userService->convertRecipientsToUsers($user, $coachers);\n\n foreach ($users as $coacher) {\n CoachRequest::create([\n 'user_id' => $coacher->id,\n 'activity_id' => $activity->id,\n 'note' => $this->request->get('note'),\n 'start_time' => $this->request->get('start_time'),\n 'end_time' => $this->request->get('end_time'),\n ]);\n }\n\n return $this->response->withOk();\n }\n\n public function createActivityTopicTriggers(Activity $activity, LoggerInterface $logger): HttpFoundation\\JsonResponse\n {\n $this->authorize('analyzeTopicTriggers', $activity);\n\n if (! $activity->hasTranscription()) {\n return new HttpFoundation\\JsonResponse(\n [\n 'error' => 'Transcription not found.',\n ],\n JsonResponse::HTTP_NOT_FOUND\n );\n }\n\n $logger->info(__METHOD__ . ': queued for analysis', [\n 'activity' => $activity->id_string,\n ]);\n\n dispatch(new ActivityAnalytics\\Job\\AnalyzeActivityTopicTriggers($activity));\n\n return new HttpFoundation\\JsonResponse(null, JsonResponse::HTTP_CREATED);\n }\n\n public function fetchActivityTopicTriggers(\n Activity $activity,\n LoggerInterface $logger,\n ActivityTopicTriggerTransformer $transformer\n ): HttpFoundation\\JsonResponse {\n $this->authorize('fetchTopicTriggers', $activity);\n\n $logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n if (! $activity->isProcessed()) {\n return new HttpFoundation\\JsonResponse([]);\n }\n\n $payload = [];\n\n if ($activity->hasTopicTriggers()) {\n $payload = $activity->getTopicTriggersSorted()\n ->map(\n static fn (Activity\\TopicTrigger $activityTopicTrigger): array\n => $transformer->transform($activityTopicTrigger)\n )\n ->values()\n ->all();\n }\n\n return new HttpFoundation\\JsonResponse($payload);\n }\n\n /**\n * @param Activity $activity\n * @param StatsTransformer $statsTransformer\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function stats(Activity $activity, StatsTransformer $statsTransformer)\n {\n $this->authorize('stream', $activity);\n\n if (! $activity->hasTranscription()) {\n return $this->response->errorNotFound('Waveform data is not yet generated.');\n }\n\n $this->response\n ->getManager()\n ->parseIncludes(['wavedata'])\n ->setSerializer(new JsonSerializer());\n\n return $this->response->withItem($activity, $statsTransformer);\n }\n\n public function destroy(Activity $activity)\n {\n $this->authorize('delete', $activity);\n\n $activity->delete();\n\n \\Log::info('Soft delete activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);\n\n return $this->response->withNoContent();\n }\n\n public function note(Activity $activity)\n {\n $this->authorize('note', $activity);\n\n $this->request->validate([\n 'note' => 'required|min:1|max:2000',\n 'time' => 'required|numeric|min:0|max:86400',\n ]);\n\n $note = $this->request->input('note');\n $time = $this->request->input('time');\n\n $this->activityService->setActivity($activity);\n $this->activityService->takeNote($this->getUser(), $note, $time);\n\n return $this->response->withCreated();\n }\n\n /**\n * Mark an activity as private.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPrivate(Activity $activity)\n {\n $this->authorize('markAsPrivate', $activity);\n\n if ($activity->is_private === false) {\n $activity->is_private = true;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * Mark an activity as public.\n *\n * @param Activity $activity\n *\n * @throws AuthorizationException\n *\n * @return mixed\n */\n public function markAsPublic(Activity $activity)\n {\n $this->authorize('markAsPublic', $activity);\n\n if ($activity->is_private) {\n $activity->is_private = false;\n $activity->save();\n\n return $this->response->withOk();\n }\n\n return $this->response->withNoContent();\n }\n\n /**\n * @throws LogicException\n */\n public function fetchCloudFrontS3MediaKeys(Activity $activity, PlaybackService $playbackService): JsonResponse\n {\n $masterTrack = $activity->masterTrack()->first();\n\n if (! $masterTrack instanceof Track) {\n throw new LogicException(sprintf('Master track not found for activity \"%s\"', $activity->getUuid()));\n }\n\n return $this->response->withArray(\n $playbackService->generateCookies(\n $masterTrack,\n $this->request->ip(),\n ),\n );\n }\n\n /**\n * @throws ValidationException\n */\n private function updateOrCreateActivitySearch(Request $request, ?Search $search = null): Search\n {\n $request->validate([\n 'name' => 'required|string|min:2|max:100',\n ]);\n\n $user = $this->getUserFromRequest($request);\n\n $searchName = $request->input('name');\n\n if ($search !== null) {\n $search->update([\n 'name' => $searchName,\n ]);\n\n return $search;\n }\n\n $request->validate([\n 'filters' => ['required', 'array', new MultidimensionalArrayMaxCharRule(limit: 255)],\n 'nudges' => 'array|max:' . count(Nudge::MAP_CHANNEL),\n 'nudges.*.channel' => 'required|in:' . implode(',', Nudge::MAP_CHANNEL),\n 'nudges.*.frequency' => 'required|in:' . implode(',', Nudge::MAP_FREQUENCY),\n 'nudges.*.expiresAt' => [\n 'required',\n 'date',\n 'after:today',\n 'before_or_equal:' . now()->addYear()->format('Y-m-d'),\n ],\n ]);\n\n $searchCriteria = Criteria::createFromRequest(\n Collection::make($request->input('filters', []))->all(),\n $user->getTimezone()\n );\n\n $filterSet = $this->activitySearch->getOnDemandPageFilterSet($searchCriteria, $user);\n $this->validateSearch($request, $filterSet, 'filters.');\n\n /** @var Search $search */\n $search = Search::create([\n 'name' => $searchName,\n 'uuid' => Uuid::uuid4()->toString(),\n 'user_id' => $user->getId(),\n ]);\n\n Collection::make($request->input('nudges', []))\n ->each(fn (array $attributes): Nudge => $this->nudgeFactory->createNudge($search, $attributes));\n\n $this->storeNamedSearchFilters(Collection::make($request->all()), $search, $filterSet, 'filters.');\n\n return $search;\n }\n\n private function resolveAccount(\n Team $team,\n Contact $contact,\n ServiceInterface $crmService,\n array $prospects,\n ): ?Account {\n $this->logger->info('Resolving account from contact');\n $account = $contact->getAccount();\n\n if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS)) {\n $this->logger->info('Team does not have feature to link activity to multiple prospects');\n\n return $account;\n }\n\n $this->logger->info('Resolving account from prospect data');\n $accountData = array_filter(\n $prospects,\n static fn (array $prospectData): bool => $prospectData['type'] === 'account'\n );\n\n if (! empty($accountData)) {\n $this->logger->info('Found account data in prospects');\n $accountData = reset($accountData);\n\n $account = $team->crm->accounts()->where('crm_provider_id', $accountData['id'])->first();\n\n if (! $account instanceof Account) {\n $this->logger->info('Account not found in database, syncing from CRM');\n $account = $crmService->syncAccount($accountData['id']);\n }\n }\n\n $this->logger->info('Resolved account', ['account' => $account->getId()]);\n\n return $account;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"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.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","depth":4,"bounds":{"left":0.44547874,"top":0.0,"width":0.29787233,"height":1.0},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Teams;\n\nuse Exception;\nuse Jiminny\\Events\\Teams\\TeamUpdated;\nuse Jiminny\\Services\\Activity\\CrmOwnerResolver;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Jiminny\\Events\\Teams\\TeamCreated;\nuse Psr\\Log\\LoggerInterface;\n\nfinal readonly class UpdateSalesforceAccount implements ShouldQueue\n{\n public function __construct(\n private ProviderRegistry $crmRegistry,\n private LoggerInterface $logger,\n ) {\n }\n\n public function handle(TeamCreated|TeamUpdated $event): void\n {\n $teamCreator = $event->teamCreator;\n if ($event->team->account_id === null || $teamCreator === null) {\n return;\n }\n\n $team = $teamCreator->getTeam();\n $integrationAdmin = $teamCreator->isCrmRequired() ? $teamCreator : $team->getOwner();\n\n try {\n $crmResolver = app(CrmOwnerResolver::class, [\n 'team' => $team,\n 'integrationAdmin' => $integrationAdmin,\n 'providerSlug' => $team->getCrmConfiguration()->getProviderName(),\n ]);\n $crmService = $crmResolver->prepareCrmService();\n } catch (Exception $e) {\n $this->logger->info('Attempt to resolve CRM service for ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n\n return;\n }\n\n try {\n $crmService->updateRecord(\n 'Account',\n $event->team->account->getCrmProviderId(),\n [\n 'Jiminny_Client_ID__c' => $event->team->getUuid(),\n 'Tier__c' => $event->team->getTier()->getTitle(),\n 'Partner__c' => (string) $event->team->partner->getId(),\n ]\n );\n } catch (Exception $e) {\n $this->logger->info('Attempt to update ' . $event->team->getUuid() . ' failed.', [\n 'exception' => $e->getCode() . ': ' . $e->getMessage(),\n ]);\n }\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7923116779312214135
|
-8385861082218261356
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
<?php
namespace Jiminny\Http\Controllers\API;
use Carbon\Carbon;
use ChaseConey\LaravelDatadogHelper\Datadog;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Jiminny\Component\ActivityAnalytics;
use Jiminny\Component\ActivitySearch;
use Jiminny\Component\ActivitySearch\FilterDefinitionCollection;
use Jiminny\Component\PlaybackPage\Comments\Services\ActivityCommentService;
use Jiminny\Component\Queue\Constants;
use Jiminny\Contracts\ES\Events\UpdateSingleEntity;
use Jiminny\Contracts\ES\UpdateTargetEnum;
use Jiminny\Contracts\Nudge\NudgeFactoryInterface;
use Jiminny\Contracts\Playlist\PlaylistTrackFactoryInterface;
use Jiminny\Contracts\Repositories\PlaylistActivityRepository;
use Jiminny\Contracts\Services\Crm\ServiceInterface;
use Jiminny\Enums\TeamSetting;
use Jiminny\Events\Activities\AiAutomation\ActivityProspectAdded;
use Jiminny\Events\Activities\Coaching\Coached;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Exceptions\LogicException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Http\Controllers\API\BaseController as Controller;
use Jiminny\Http\Controllers\CommentContextInterface;
use Jiminny\Http\Responses\Api\AbstractResponse;
use Jiminny\Http\Responses\Api\Response;
use Jiminny\Http\Serializers\JsonSerializer;
use Jiminny\Http\Transformers\ActivityCommentTransformer;
use Jiminny\Http\Transformers\ActivityTopicTriggerTransformer;
use Jiminny\Http\Transformers\ActivityTransformer;
use Jiminny\Http\Transformers\AvailabilityNotificationTransformer;
use Jiminny\Http\Transformers\CoachingFeedbackTransformer;
use Jiminny\Http\Transformers\CoachingSectionsTransformer;
use Jiminny\Http\Transformers\SearchTransformer;
use Jiminny\Http\Transformers\StatsTransformer;
use Jiminny\Jobs\Crm\SaveActivity;
use Jiminny\Jobs\Crm\UpdateStage;
use Jiminny\Jobs\Telephony\StartRecording;
use Jiminny\Jobs\Telephony\StopRecording;
use Jiminny\Jobs\Telephony\ToggleRecording;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Activity\CoachRequest;
use Jiminny\Models\Activity\Comment;
use Jiminny\Models\Activity\Search;
use Jiminny\Models\Activity\SearchFilter;
use Jiminny\Models\Activity\Share;
use Jiminny\Models\CoachingFeedback;
use Jiminny\Models\CoachingSection;
use Jiminny\Models\CoachingSectionCriterion;
use Jiminny\Models\CoachingSectionFeedback;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\LayoutEntity;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Lead;
use Jiminny\Models\Nudge;
use Jiminny\Models\PlaybookCategory;
use Jiminny\Models\Playlist;
use Jiminny\Models\Stage;
use Jiminny\Models\Team;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Repositories\CoachingFeedbackRepository;
use Jiminny\Repositories\ElasticActivityRepository;
use Jiminny\Repositories\TeamRepository;
use Jiminny\Rules\CrmReference;
use Jiminny\Rules\MultidimensionalArrayMaxCharRule;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Activity\CrmOwnerResolver;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\PlaybackService;
use Jiminny\Services\UserService;
use Jiminny\VO\Repository\OnDemandActivitySearch\Criteria;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Sentry;
use Symfony\Component\HttpFoundation;
final class ActivityController extends Controller implements CommentContextInterface
{
// Number of minutes to look back on activities. i.e. a timeout on activity duration.
private const int LOOK_BACK = 180;
public function __construct(
private ProviderRegistry $providerRegistry,
private ActivityService $activityService,
Response $response,
private UserService $userService,
private ActivitySearch\Service\ActivitySearch $activitySearch,
private NudgeFactoryInterface $nudgeFactory,
private ActivityCommentService $activityCommentService,
private LoggerInterface $logger,
private readonly CoachingFeedbackRepository $coachingFeedbackRepository,
private readonly TeamRepository $teamRepository,
) {
parent::__construct($response);
}
public static function getCommentImplementation(): string
{
return Comment::class;
}
public function delete()
{
$this->request->validate([
'*' => 'uuid:activities',
]);
$deletedIds = [];
foreach ($this->request->all() as $activityId) {
$activity = Activity::idOrUuId($activityId);
try {
if ($this->authorize('delete', $activity)) {
$activity->delete();
$deletedIds[] = $activityId;
\Log::info('Soft deleted activity ' . $activity->id_string . ' by user ' . $this->getUser()->id);
}
} catch (AuthorizationException $authorizationException) {
// They didn't have permission.
}
}
return $this->response->withArray($deletedIds);
}
public function update(Request $request, Activity $activity)
{
$this->authorize('updateMetadata', $activity);
$request->validate([
'title' => 'string|max:250',
'category_id' => 'uuid:playbook_categories',
'language' => [
new In(
LanguageDialect::query()
->with('language')
->cursor()
->map(static function (LanguageDialect $languageDialect): string {
return $languageDialect->getLanguageLocale();
})
->all()
),
],
]);
if ($request->has('title')) {
$activity->title = $request->input('title');
}
if ($request->has('category_id')) {
$category = PlaybookCategory::uuid($request->input('category_id'));
if ($category->playbook->team_id !== $request->user()->team_id) {
return $this->response->errorNotFound('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
if ($request->has('language')) {
if (! $activity->isInProgress()) {
return $this->response->withError(
'Activity language can only be set while the meeting is in progress.',
400
);
}
$activity->setLanguageCode($request->input('language'));
}
$activity->save();
return $this->response->withOk();
}
// XXX: This should be merged with the update method.
/**
* @param Activity $activity
*
* @throws AuthorizationException
* @throws SocialAccountTokenInvalidException
*
* @return mixed
*/
public function summarize(Activity $activity): mixed
{
$this->logger->info('[Log Activity] Summarizing activity ', [
'activityId' => $activity->getUuid(),
'payload' => $this->request->all(),
]);
$this->authorize('update', $activity);
$this->logger->info('[Log Activity] Validating summary');
// Validate the payload.
$this->validateSummary($activity);
// All objects must belong to this team.
/** @var User $user */
$user = $this->request->user();
$team = $user->getTeam();
$crmService = $this->providerRegistry->get($team->crm->provider);
try {
$crmUser = $user;
if ($user->isCrmRequired() === false) {
$crmUser = $team->owner;
}
$crmService->setUser($crmUser);
} catch (SocialAccountTokenInvalidException $accountTokenInvalidException) {
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($accountTokenInvalidException->getMessage());
}
$rawEntities = $this->request->input('entities');
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid(
$this->request->input('layout_id')
);
// Delay execution of CRM jobs to avoid locking issues.
$jobDelay = 0;
// If we have arrived from a notification, mark it as read.
$notificationId = $this->request->input('nId');
if ($notificationId) {
$notification = $user->unreadNotifications->where('id', $notificationId)->first();
if ($notification) {
$notification->markAsRead();
}
}
$title = $this->request->input('title');
$prospects = $this->request->input('prospects');
$opportunityId = $this->request->input('opportunity_id');
$stageId = $this->request->input('stage_id');
$categoryId = $this->request->input('category_id');
$summary = $this->request->input('summary');
$crmProviderId = $this->request->input('crm_id');
$isInternal = $this->request->input('is_internal') ?? false;
$lead = null;
$category = null;
$account = null;
$contact = null;
$opportunity = null;
$stage = null;
$callStage = null;
foreach ($prospects as $prospectData) {
$objectId = $prospectData['id'];
if ($objectId === null) {
continue;
}
$objectType = $prospectData['type'];
$this->logger->info('debug', ['prospect_data' => $prospectData]);
try {
if ($objectType === null) {
$this->logger->info('no object type');
if ($crmService instanceof SupportsObjectTypeParseInterface) {
$objectType = $crmService->parseObjectType($objectId);
}
}
switch ($objectType) {
case 'lead':
$this->logger->info('Processing lead');
/** @var Lead|null $lead */
$lead = $team->crm->leads()->where('crm_provider_id', $objectId)->first();
// Lead does not exist locally, import it.
if ($lead === null) {
$this->logger->info('Lead does not exist locally');
/** @var Lead $lead */
$lead = $crmService->syncLead($objectId);
}
$this->logger->info('Lead found', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
if ($stageId === null) {
$this->logger->info('Stage ID is null');
// If it was not provided, just assume it is the current stage.
$callStage = $lead->stage;
break;
}
$this->logger->info('Looking for stage');
// Determine if they have changed the stage.
/** @var Stage $stage */
$stage = $team->crm->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_LEAD)
->firstOrFail();
$this->logger->info('Stage found', ['stageId' => $stage->id, 'lead_stage' => $lead->stage_id]);
if ($lead->stage_id && $lead->stage_id !== $stage->id) {
$this->logger->info('Stage has changed');
// Storage current stage on activity.
$callStage = $lead->stage;
// The stage has changed, update in remote CRM.
dispatch(new UpdateStage($activity, $lead, $callStage, $stage));
$this->logger->info(
sprintf(
'[%s] User changing lead stage from %s to %s',
$crmService->getDisplayName(),
$callStage->getName(),
$stage->getName()
),
[
'user' => $user->getUuid(),
'lead' => $lead->getUuid(),
]
);
} else {
$this->logger->info('Stage has not changed');
// Stage remains as current.
$callStage = $stage;
}
break;
case 'account':
$this->logger->info('Processing account');
// If the object is not a lead, it should be an account.
$account = $team->crm->accounts()->where('crm_provider_id', $objectId)->first();
// Account does not exist locally, import it.
if ($account === null) {
$this->logger->info('Account does not exist locally');
$account = $crmService->syncAccount($objectId);
}
$this->logger->info('Account found', ['accountId' => $account->id]);
break;
case 'contact':
$this->logger->info('processing contact');
$contact = $team->crm->contacts()->where('crm_provider_id', $objectId)->first();
// Contact does not exist locally, import it.
if (! $contact instanceof Contact) {
$this->logger->info('contact does not exist locally');
$contact = $crmService->syncContact($objectId);
}
$this->logger->info('resolving account');
$account = $this->resolveAccount($team, $contact, $crmService, $prospects);
break;
}
// If they have specified an opportunity, retrieve this with stage.
if ($opportunityId) {
$this->logger->info('opportunity id is set');
$opportunity = $team->crm->opportunities()->where('crm_provider_id', $opportunityId)->first();
// Opportunity does not exist locally, import it.
if ($opportunity === null) {
$this->logger->info('opportunity does not exist locally');
$opportunity = $crmService->syncOpportunity($opportunityId);
}
if ($stageId === null) {
$this->logger->info('stage id is null');
// If it was not provided, just assume it is the current stage.
$callStage = $opportunity->stage ?? null;
} else {
$this->logger->info('looking for stage');
/** @var ?Stage $opportunityStage */
$opportunityStage = $team->crm
->stages()
->uuid($stageId, false)
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
// There is a chance we still cannot import this opportunity.
if ($opportunityStage !== null && $opportunity !== null && $opportunity->stage_id !== $opportunityStage->id) {
$this->logger->info('opportunity stage has changed');
// Storage current stage on activity.
$callStage = $opportunity->stage;
dispatch(new UpdateStage($activity, $opportunity, $callStage, $opportunityStage));
$this->logger->info(
sprintf(
'[%s] User changing opportunity stage from %s to %s',
$crmService->getDisplayName(),
$callStage->name,
$opportunityStage->name
),
[
'userId' => $user->id_string,
'opportunityId' => $opportunity->id_string,
]
);
} else {
$this->logger->info('opportunity stage has not changed');
// Stage remains as current.
$callStage = $opportunityStage;
}
}
}
if ($crmProviderId) {
// Cast $crmProviderId to string otherwise it won't use database index for some records
$linkedActivity = Activity::where('crm_provider_id', (string) $crmProviderId)->first();
// Check if this activity has already been assigned to a different activity.
if ($linkedActivity && $linkedActivity->id !== $activity->id) {
throw new InvalidArgumentException(
'Sorry, the linked task has already been logged under a different call. '
. 'Please choose another linked task.'
);
}
}
} catch (InvalidArgumentException $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorWrongArgs($exception->getMessage());
} catch (Exception $exception) {
$this->logger->error('Failed to process prospect', [
'prospect_data' => $prospectData,
'reason' => $exception->getMessage(),
]);
// Return a JSON response with the response array and status code.
return $this->response->errorInternalError(
'Sorry, an error occurred. Please try again or reach out to support if the problem continues.'
);
}
}
if ($categoryId) {
$category = PlaybookCategory::uuid($categoryId);
if ($category->playbook->team_id !== $team->id) {
throw new InvalidArgumentException('Sorry, this category does not belong to your playbook.');
}
$activity->playbook_category_id = $category->id;
}
$this->logger->info('Prospect data', [
'lead_id' => $lead?->getId(),
'account_id' => $account?->getId(),
'contact_id' => $contact?->getId(),
'opportunity_id' => $opportunity?->getId(),
'stage_id' => $stage?->getId(),
]);
if ($title) {
$activity->title = $title;
}
if ($summary) {
$activity->summary = $summary;
}
if ($crmProviderId) {
$activity->crm_provider_id = $crmProviderId;
}
if ($callStage) {
$this->logger->info('Setting stage id', ['stageId' => $callStage->id]);
$activity->stage_id = $callStage->id;
}
if ($lead) {
$this->logger->info('Setting lead id', ['leadId' => $lead->id]);
$activity->lead_id = $lead->id;
// If we are changed from an account > lead, unset the account data.
$this->logger->info('Unsetting account id, opportunity id, contact id, value');
$activity->account_id = null;
$activity->opportunity_id = null;
$activity->contact_id = null;
$activity->value = null;
}
if ($account) {
$this->logger->info('Setting account id', ['accountId' => $account->id]);
$activity->account_id = $account->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('unsetting lead id');
$activity->lead_id = null;
// Unset the contact if switching different accounts. Will be set up below if still applicable.
if (! $team->hasFeature(FeatureEnum::LINK_ACTIVITY_TO_MULTIPLE_PROSPECTS) || empty($contact)) {
$this->logger->info('Unsetting contact id');
$activity->contact_id = null;
}
}
if ($opportunity) {
$this->logger->info('setting opportunity id', ['opportunityId' => $opportunity->id]);
$this->logger->info('unsetting lead id');
$activity->opportunity_id = $opportunity->id;
$activity->value = $opportunity->value;
// If we are changed from an lead > account, unset the lead data.
$activity->lead_id = null;
}
if ($contact) {
$this->logger->info('setting contact id', ['contactId' => $contact->id]);
$activity->contact_id = $contact->id;
// If we are changed from an lead > account, unset the lead data.
$this->logger->info('Unsetting lead id');
$activity->lead_id = null;
}
$activity->is_internal = $isInternal;
$activity->save();
$activity->refresh();
$this->logger->notice('Activity saved', [
'activity_id' => $activity->getId(),
'lead_id' => $activity->lead_id,
'account_id' => $activity->account_id,
'contact_id' => $activity->contact_id,
'opportunity_id' => $activity->opportunity_id,
'stage_id' => $activity->stage_id,
'crm_provider_id' => $activity->getCrmProviderId(),
]);
// Store entities as field data on the activity.
$updatedData = $this->storeEntities($crmService, $activity, $layout, $rawEntities);
if ($activity->isLoggable()) {
// Follow-up Task or Event data.
$followupData = $this->fetchFollowupEntities($crmService, $layout, $rawEntities);
$this->logger->info('CRM LOG manual log triggered', [
'activityId' => $activity->getUuid(),
'followupData' => $followupData,
'userId' => $user->getUuid(),
]);
// Store data in the CRM.
// ++add check for crm_required
$job = new SaveActivity($activity, $followupData);
if ($updatedData) {
$job->delay(Carbon::now()->addMinutes($jobDelay));
}
dispatch($job);
// Manually dispatch log for Opportunity or Prospect added
if ($activity->hasOpportunity() || $activity->hasProspect()) {
event(new ActivityProspectAdded(
activity: $activity,
eventSource: 'manually-log-crm-data'
));
}
}
return $this->response->withOk();
}
/**
* Extract any activity data to be upserted in the Lead/Opportunity/Task etc in the CRM.
*
* @param ServiceInterface $service
* @param Activity $activity
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function storeEntities(ServiceInterface $service, Activity $activity, Layout $layout, array $entities): array
{
$updatedData = [];
$existingData = $activity->data()->get();
// We need to delete any existing data to overwrite with latest values.
$activity->data()->delete();
$layoutEntities = $layout->entities()
->with('field', 'parent')
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->get();
/** @var LayoutEntity $entity */
foreach ($layoutEntities as $entity) {
// If the user has provided a value for this entity
if (array_key_exists($entity->id_string, $entities)) {
$value = $entities[$entity->id_string];
// Convert raw data into values that the CRM can consume.
if ($value) {
$value = $service->normalizeValue($entity->field->type, $value);
}
// Check the field is part of the activity-summary section.
if ($entity->parent && $entity->parent->label === 'activity-summary' && $value) {
// This is the internal database ID, not the external CRM ID.
$objectId = null;
switch ($entity->field->object_type) {
case Field::OBJECT_ACCOUNT:
$objectId = $activity->account_id;
break;
case Field::OBJECT_CONTACT:
$objectId = $activity->contact_id;
break;
case Field::OBJECT_OPPORTUNITY:
$objectId = $activity->opportunity_id;
break;
case Field::OBJECT_LEAD:
$objectId = $activity->lead_id;
break;
case Field::OBJECT_TASK:
case Field::OBJECT_EVENT:
$objectId = $activity->id;
break;
}
if ($objectId) {
/** @var FieldData $data */
$data = $activity->data()->create([
'crm_layout_entity_id' => $entity->id,
'crm_field_id' => $entity->crm_field_id,
'object_type' => $entity->field->object_type,
'object_id' => $objectId,
'value' => $value,
]);
// Never send read-only field data to the CRM.
if ($entity->read_only === false && $entity->is_visible) {
$existingValue = $existingData
->where('crm_layout_entity_id', $entity->id)
->where('crm_field_id', $entity->crm_field_id)
->where('object_type', $entity->field->object_type)
->where('object_id', $objectId)
->first();
// If the field was actually changed, we need to reflect this in the CRM too.
if ($existingValue === null || $existingValue->value !== $value) {
$updatedData[] = $data->id;
}
}
}
}
}
}
return $updatedData;
}
/**
* Extract any followup data to be dispatched in a job to create a new Task/Event in the CRM.
*
* @param ServiceInterface $crmService
* @param Layout $layout
* @param array $entities The raw entity data from user
*
* @return array
*/
private function fetchFollowupEntities(ServiceInterface $crmService, Layout $layout, array $entities): array
{
$fieldData = [];
foreach ($entities as $entityId => $value) {
// Only bother with fields that have a value.
if ($value) {
// Extract the entity from the UUID. Check the field is valid and part of the follow-up section.
$entity = $layout->entities()
->uuid($entityId, false)
->whereHas('parent', function ($query) {
$query->where('label', 'follow-up');
})
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->first();
if ($entity) {
// Convert raw data into values that the CRM can consume.
$value = $crmService->normalizeValue($entity->field->type, $value);
// Add the field and value to the payload.
$fieldData += [
$entity->field->crm_provider_id => $value,
];
}
}
}
return $fieldData;
}
/**
* @param Activity $activity
*/
private function validateSummary(Activity $activity): void
{
$team = $activity->user->team;
$crmProvider = $team->crm->provider;
$attributes = [];
$rules = [
'layout_id' => 'required|uuid:crm_layouts,crm_configuration_id,' . $team->crm_id,
'title' => 'string|max:250',
'prospects' => 'required|array',
'opportunity_id' => new CrmReference($crmProvider),
'category_id' => 'uuid:playbook_categories|required_unless:is_internal,true',
'stage_id' => 'uuid:stages,team_id,' . $team->id, // Todo: move to proper validator
'summary' => 'max:50000',
'nId' => 'exists:notifications,id',
'crm_id' => new CrmReference($crmProvider),
'entities' => 'array',
'is_internal' => 'boolean',
];
/** @var Layout $layout */
$layout = $team->crm->layouts()->uuid($this->request->input('layout_id'));
// Only validate fields, not headers etc. If not loggable, we don't care about follow-up section.
$entities = $layout->entities()
->where('read_only', 0)
->whereHas('field', function ($query) {
$query->where('is_selectable', 1);
})
->whereHas('parent', function ($query) use ($activity) {
if ($activity->isLoggable() === false) {
$query->where('label', '<>', 'follow-up');
}
});
$isInternal = $this->request->input('is_internal', false);
foreach ($entities->get() as $entity) {
$rules += $this->buildFieldValidator($entity, $isInternal);
$attributes += $this->buildFieldMessage($entity);
}
$this->request->validate($rules, [], $attributes);
}
private function buildFieldValidator(LayoutEntity $entity, bool $isInternal): array
{
return [
'entities.' . $entity->id_string => $entity->getValidator($isInternal),
];
}
/**
* @param LayoutEntity $entity
*
* @return array
*/
private function buildFieldMessage(LayoutEntity $entity): array
{
$label = $entity->label;
if ($label === null) {
$label = $entity->field->label;
}
return [
'entities.' . $entity->id_string => $label,
];
}
public function search(Request $request, ElasticActivityRepository $repository): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->debugLog(
$user,
'User extracted from request',
['user' => $user->getId(), 'tz' => $user->getTimezone()]
);
$searchCriteria = Criteria::createFromRequest($request->all(), $user->getTimezone());
$this->debugLog(
$user,
'ActivitySearch criteria built',
['searchCriteria' => $searchCriteria]
);
$filterSet = $this->activitySearch->getHomepageFilterSet($searchCriteria, $user);
$this->debugLog($user, 'FilterSet built', ['filterSet' => $filterSet]);
$this->validateSearch($request, $filterSet);
$this->debugLog($user, 'Request validated');
$searchResponse = $repository->onDemandSearch($user, $searchCriteria, $filterSet);
/** @var Collection<Activity> $activities */
$activities = $searchResponse['results'];
$this->debugLog($user, 'Activities ES response extracted');
$hideInternalMeetingsSetting = $this->teamRepository->getTeamSettingByTeamId(
$user->getTeamId(),
TeamSetting::HIDE_INTERNAL_SCHEDULED_MEETINGS->name(),
);
if ($hideInternalMeetingsSetting?->getValue() === '1') {
$activities = $activities->filter(function (Activity $activity) {
if ($activity->is_internal && empty($activity->actual_start_time)) {
return false;
}
return true;
});
}
$this->debugLog($user, 'Internal meetings (?!) filtered');
$this->response->getManager()
->parseIncludes([
'category',
'organizer.group',
'prospect',
'stage',
'opportunity',
'stats',
'scorecards',
'masterTrack',
'activeParticipants',
'notification',
])
->setSerializer(new JsonSerializer());
$transformerExcludes = $this->request->input('exclude');
if ($transformerExcludes) {
$this->response->getManager()->parseExcludes($transformerExcludes);
}
$this->debugLog($user, 'Response Manager (?!) applied');
$transformer = new ActivityTransformer();
$transformer->setConsumer($user);
$this->debugLog($user, 'Activity Transformer added');
$resource = new \League\Fractal\Resource\Collection($activities, $transformer);
$page = $searchCriteria->getPageNumber();
$this->debugLog($user, 'Search criteria page number called', ['page' => $page]);
$histogram = array_pluck(array_get($searchResponse, 'histogram.buckets', []), 'doc_count', 'key_as_string');
$this->debugLog($user, 'Histogram generated. Response is ready.', ['histogram' => $histogram]);
return $this->response->withArray([
'pagination' => [
'total' => $searchResponse['totalHits'],
'current' => $page,
'prev' => max($page - 1, 1),
'next' => $page + 1,
],
'results' => $this->response->getManager()->createData($resource)->toArray(),
'histogram' => $histogram,
]);
}
private function debugLog(User $user, string $logMessage, ?array $context = []): void
{
// Debug for Learning People Only
if ($user->getTeamId() !== 260) {
return;
}
Log::notice(
sprintf('[activity-search-controller] %s', $logMessage),
$context
);
}
/** @throws ValidationException */
private function validateSearch(Request $request, FilterDefinitionCollection $filterSet, ?string $prefix = null): void
{
$rules = [
'exclude' => 'array',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
];
if ($prefix !== null && mb_strpos($prefix, '.') !== false) {
$rules[rtrim($prefix, '.')] = sprintf(
'required|array|max:%d',
$filterSet->count()
);
}
$validationRules = $filterSet->getValidationRules($prefix)
->merge($rules)
->all();
$request->validate($validationRules);
}
public function createActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$search = $this->updateOrCreateActivitySearch($request);
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function updateActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('update', $search);
$this->updateOrCreateActivitySearch($request, $search);
return $this->response->withOk();
}
private function storeNamedSearchFilters(
Collection $request,
Search $search,
FilterDefinitionCollection $filterSet,
?string $prefix = null,
): self {
$arrayTypeProperties = $filterSet
->getPropertyTypes([
FilterDefinitionCollection::PROPERTY_TYPE_ARRAY,
])
->all();
$supportedRequestProperties = $filterSet->getSupportedRequestProperties($prefix);
foreach ($supportedRequestProperties as $requestPropertyName) {
if (! array_has($request, $requestPropertyName)) {
continue;
}
/** @var string|string[] $propertyValue */
$propertyValue = array_get($request, $requestPropertyName);
$propertyName = $prefix === null
? $requestPropertyName
: mb_substr($requestPropertyName, mb_strlen($prefix));
$isArrayType = array_has($arrayTypeProperties, $propertyName);
if (! $isArrayType) {
/** @var string $requestPropertyValue */
$search->filters()->updateOrCreate(
[
'filter' => $propertyName,
],
[
'value' => $propertyValue,
]
);
continue;
}
/** @var string[] $requestPropertyValue */
/** @var SearchFilter[]|Collection $existingFilterValues */
$existingFilterValuesKeyed = $search->filters()
->where('filter', $propertyName)
->get()
->keyBy('id');
// Iterate over values provided as request parameters
foreach ($propertyValue as $value) {
/** @var SearchFilter|null $valueFilter */
$valueFilter = $search->filters()
->where(
[
'filter' => $propertyName,
'value' => $value,
]
)
->first();
if ($valueFilter !== null) {
// Remove filter value pair from list to be deleted
$existingFilterValuesKeyed->forget($valueFilter->id);
} else {
// Add new filter/value pair
$search->filters()->updateOrCreate([
'filter' => $propertyName,
'value' => $value,
]);
}
}
// Delete filter value pairs for this filter that no longer exist in request parameters
foreach ($existingFilterValuesKeyed as $existingFilter) {
$existingFilter->delete();
}
}
/** @var Collection<int, SearchFilter> $filtersKeyed */
$filtersKeyed = $search->filters()->get()->keyBy('filter');
// wipe removed filters from this search
foreach ($filtersKeyed as $filterName => $filter) {
if (array_has($request, $prefix . $filterName)) {
continue;
}
// Remove all filter values for this filter
$search->filters()->where('filter', $filterName)->delete();
}
return $this;
}
/**
* @throws AuthorizationException
*/
public function fetchActivitySearch(
Search $search,
Request $request,
SearchTransformer $searchTransformer,
): JsonResponse {
$this->authorize('view', $search);
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
$search->loadCount('automatedReports');
return $this->response->withItem(
$search,
$searchTransformer
->withConsumer($user)
);
}
public function listActivitySearch(Request $request, SearchTransformer $searchTransformer): JsonResponse
{
/** @var User $user */
$user = $request->user();
$this->response
->getManager()
->setSerializer(new JsonSerializer());
return $this->response->withCollection(
$user->searches()->withCount('automatedReports')->get(),
$searchTransformer
->withConsumer($user)
);
}
/**
* Deletes a saved search
*
* @param Request $request
* @param Search $search
*
* @throws Exception
*
* @return JsonResponse
*/
public function deleteActivitySearch(Request $request, Search $search): JsonResponse
{
$this->authorize('delete', $search);
// Disable and orphan any AutomatedReports that use this search
$search->automatedReports()->withTrashed()->update([
'activity_search_id' => null,
'status' => false,
]);
// Delete filters and the search itself
$search->filters()->delete();
$search->delete();
return $this->response->withOk();
}
public function live(Request $request, ElasticActivityRepository $repository): JsonResponse
{
$user = $this->getUserFromRequest($request);
$this->request->validate([
'sort_direction' => 'in:asc,desc',
'limit' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
]);
$activities = $repository->getLiveCoachingEligibleActivities(
user: $user,
lookBackMinutes: self::LOOK_BACK,
limit: (int) $this->request->input('limit', 25),
page: (int) $this->request->input('page', 1),
sortBy: ['actual_start_time', 'scheduled_start_time'],
sortDirection: (string) $this->request->input('sort_direction', 'asc'),
);
$this->response
->getManager()
->parseIncludes(['organizer.group', 'prospect'])
->setSerializer(new JsonSerializer());
return $this->response->withCollection($activities, new ActivityTransformer());
}
/**
* @param Activity $activity
*
* @throws AuthorizationException
*
* @return mixed
*/
public function show(Activity $activity, ActivityService $activityService): JsonResponse
{
$this->authorize('show', $activity);
$user = $activity->getUser();
$team = $user->getTeam();
// Sync the opportunity with the latest data if possible.
if ($activity->opportunity_id) {
try {
$integrationAdmin = $user->isCrmRequired() ? $user : $team->getOwner();
$crmResolver = app(CrmOwnerResolver::class, [
'team' => $team,
'integrationAdmin' => $integrationAdmin,
'providerSlug' => $team->getCrmConfiguration()->getProviderName(),
]);
$crmService = $crmResolver->prepareCrmService();
$crmService->syncOpportunity($activity->opportunity->crm_provider_id);
} catch (Exception $exception) {
// Move on.
}
}
$activityData = $activityService->getActivityData($this->request->user(), $activity);
return response()->json($activityData);
}
public function createRecording(Activity $activity)
{
$this->authorize('record', $activity);
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Tell Twilio to start recording this activity.
if ($activity->recording_state === Activity::RECORDING_OFF) {
$job = (new StartRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withCreated();
}
return $this->response->errorGone('Activity is already recording.');
}
public function updateRecording(Request $request, Activity $activity)
{
$this->authorize('record', $activity);
$request->validate([
'preference' => 'boolean',
'state' => [
'string',
Rule::in([
Activity::RECORDING_IN_PROGRESS,
Activity::RECORDING_PAUSED,
]),
],
]);
if ($request->has('state')) {
if ($activity->hasRecordingReasonComplianceRestricted()) {
return $this->response->errorGone('Recording this number has been disabled by your organization.');
}
// Toggle the recording state between paused and resumed.
if (! $activity->isRecordingState(Activity::RECORDING_OFF)) {
$job = (new ToggleRecording($activity, $request->input('state')))
->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Recording is not toggleable.');
}
if ($request->has('preference')) {
$activity->update([
'recording_preference' => $request->input('preference') ? 1 : 0,
]);
return $this->response->withOk();
}
return $this->response->errorWrongArgs('Something went wrong');
}
public function stopRecording(Activity $activity)
{
$this->authorize('stopRecord', $activity);
// Tell Twilio to stop recording this activity.
if ($activity->isRecordingState(Activity::RECORDING_IN_PROGRESS)) {
$job = (new StopRecording($activity))->onQueue(Constants::QUEUE_CONFERENCES);
dispatch($job);
return $this->response->withOk();
}
return $this->response->errorGone('Activity is not recording.');
}
/**
* Add activity to this user's favorites playlist
*
* @throws AuthorizationException
*/
public function favorite(Activity $activity, PlaylistActivityRepository $playlistActivityRepository): JsonResponse
{
$this->authorize('favorite', $activity);
$user = $this->getUserFromRequest($this->request);
$favorite = $activity->wasFavoritedBy($user);
$name = $activity->activity_title ?? '';
// It needs to check at least one record.
if (! $favorite) {
$favoritePlaylist = $user->favoritePlaylist();
$playlistActivity = $playlistActivityRepository->findByBaseActivityUserAndPlaylist(
$activity,
$user,
$favoritePlaylist
);
if ($playlistActivity !== null) {
$playlistActivity->update(
// Just update, don't sort.
['start_time' => 0, 'name' => mb_strimwidth($name, 0, 100)],
);
} else {
$playlistActivity = $activity->playlistActivities()->create([
'playlist_id' => $favoritePlaylist->getId(),
'user_id' => $user->getId(),
'start_time' => 0,
'name' => mb_strimwidth($name, 0, 100),
]);
// Sort it on top.
$playlistActivity->update(
[
'sort' => $playlistActivityRepository->calculateNewSortOrder(
null,
$playlistActivity,
),
],
);
}
$playlistActivityRepository->calculateNewSortOrder(null, $playlistActivity);
return new JsonResponse([], JsonResponse::HTTP_CREATED);
}
return new JsonResponse(
[
'error' => [
'code' => AbstractResponse::CODE_CONFLICT,
'http_code' => JsonResponse::HTTP_CONFLICT,
'message' => 'Resource Already Exists',
],
],
JsonResponse::HTTP_CONFLICT,
);
...
|
71050
|
NULL
|
NULL
|
NULL
|