|
3574
|
69
|
83
|
2026-04-12T12:56:58.258596+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-12/1775 /Users/lukas/.screenpipe/data/data/2026-04-12/1775998618258_m1.jpg...
|
Boosteroid
|
Boosteroid
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Boosteroid‹$0 lolA100% <478Sun 12 Apr 15:56:58- Boosteroid‹$0 lolA100% <478Sun 12 Apr 15:56:58-zshDOCKERO ₴1DEV (-zsh)О 82"n": 2,"text_source": "accessibility"3,},{},{},},{},{"app_name": "Code","n": 2,"text_source": "ocr""app_name": "Finder","n": 2,"text_source": "hybrid""app_name" : "Raycast","n": 2,"text_source": "accessibility""app_name": "Preview","n": 1,"text_source": "hybrid""app_name": "QuickTime Player","n": 1,"text_source": "accessibility""app_name": "System Settings","n": 1,"text_source": "accessibility""app_name": "iTerm2","n": 1,"text_source": "hybrid""app_name": "iTerm2","n": 1,"text_source": "ocr"Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ |APP (-zsh)• *3-zsh• ₴4-zsh• $5-zsh-zsh®T8O 87* Unable to acces...• *8...
|
NULL
|
-9075622617576216100
|
NULL
|
click
|
ocr
|
NULL
|
Boosteroid‹$0 lolA100% <478Sun 12 Apr 15:56:58- Boosteroid‹$0 lolA100% <478Sun 12 Apr 15:56:58-zshDOCKERO ₴1DEV (-zsh)О 82"n": 2,"text_source": "accessibility"3,},{},{},},{},{"app_name": "Code","n": 2,"text_source": "ocr""app_name": "Finder","n": 2,"text_source": "hybrid""app_name" : "Raycast","n": 2,"text_source": "accessibility""app_name": "Preview","n": 1,"text_source": "hybrid""app_name": "QuickTime Player","n": 1,"text_source": "accessibility""app_name": "System Settings","n": 1,"text_source": "accessibility""app_name": "iTerm2","n": 1,"text_source": "hybrid""app_name": "iTerm2","n": 1,"text_source": "ocr"Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ |APP (-zsh)• *3-zsh• ₴4-zsh• $5-zsh-zsh®T8O 87* Unable to acces...• *8...
|
NULL
|
|
3575
|
69
|
84
|
2026-04-12T12:56:59.100228+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-12/1775 /Users/lukas/.screenpipe/data/data/2026-04-12/1775998619100_m1.jpg...
|
Boosteroid
|
Boosteroid
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Boosteroid‹$0 lolA100% <478Sun 12 Apr 15:56:58- Boosteroid‹$0 lolA100% <478Sun 12 Apr 15:56:58-zshDOCKERO ₴1DEV (-zsh)О 82"n": 2,"text_source": "accessibility"3,},{},{},},{},{"app_name": "Code","n": 2,"text_source": "ocr""app_name": "Finder","n": 2,"text_source": "hybrid""app_name" : "Raycast","n": 2,"text_source": "accessibility""app_name": "Preview","n": 1,"text_source": "hybrid""app_name": "QuickTime Player","n": 1,"text_source": "accessibility""app_name": "System Settings","n": 1,"text_source": "accessibility""app_name": "iTerm2","n": 1,"text_source": "hybrid""app_name": "iTerm2","n": 1,"text_source": "ocr"Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ |APP (-zsh)• *3-zsh• ₴4-zsh• $5-zsh-zsh®T8O 87* Unable to acces...• *8...
|
NULL
|
-9075622617576216100
|
NULL
|
click
|
ocr
|
NULL
|
Boosteroid‹$0 lolA100% <478Sun 12 Apr 15:56:58- Boosteroid‹$0 lolA100% <478Sun 12 Apr 15:56:58-zshDOCKERO ₴1DEV (-zsh)О 82"n": 2,"text_source": "accessibility"3,},{},{},},{},{"app_name": "Code","n": 2,"text_source": "ocr""app_name": "Finder","n": 2,"text_source": "hybrid""app_name" : "Raycast","n": 2,"text_source": "accessibility""app_name": "Preview","n": 1,"text_source": "hybrid""app_name": "QuickTime Player","n": 1,"text_source": "accessibility""app_name": "System Settings","n": 1,"text_source": "accessibility""app_name": "iTerm2","n": 1,"text_source": "hybrid""app_name": "iTerm2","n": 1,"text_source": "ocr"Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ |APP (-zsh)• *3-zsh• ₴4-zsh• $5-zsh-zsh®T8O 87* Unable to acces...• *8...
|
3574
|
|
57735
|
1240
|
16
|
2026-04-20T12:03:57.446838+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776686637446_m1.jpg...
|
Firefox
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PostmanFileEditViewWindowHelpJalo100% K78• Mon 20 PostmanFileEditViewWindowHelpJalo100% K78• Mon 20 Apr 15:03:57meet.google.com/cxs-eips-npt?authuser=0=00 5Nikolay IvanovSteliyan GeorgievNikolay NikolovNikolay YankovLukas Kovalik3:03 PM | [Platform] Refinement ®2:09...
|
NULL
|
-9075469878982479838
|
NULL
|
visual_change
|
ocr
|
NULL
|
PostmanFileEditViewWindowHelpJalo100% K78• Mon 20 PostmanFileEditViewWindowHelpJalo100% K78• Mon 20 Apr 15:03:57meet.google.com/cxs-eips-npt?authuser=0=00 5Nikolay IvanovSteliyan GeorgievNikolay NikolovNikolay YankovLukas Kovalik3:03 PM | [Platform] Refinement ®2:09...
|
NULL
|
|
56653
|
1222
|
50
|
2026-04-20T11:19:46.321526+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776683986321_m2.jpg...
|
Firefox
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
40100% C7Mon 20 Apr 14:19:46VIewWindowmelpXx Hubsp 40100% C7Mon 20 Apr 14:19:46VIewWindowmelpXx Hubspot vYour team is now on the Free plan with 1 admin. You retain editing access and other members are read-only. View team permissions to see who can edit, or upgrade to restore collaborationPOST search dealsPOST Read a batch of obiec • POST Read a batch of assoc •Engagements › read call{{baseUrl)} /crm/v3/objects/deal/287386441?associations=contact&associations=companyE Docs Params • Authorization • Headers 8 Body Scripts SettingsQuery Paramsassociationsv associationsGET Read• SearchGET ReadGET read calllGET Get Engagement (v1PATCH httos://api.hubapi.com# SaveV COLLECTIONS• Associations V4|• CMS - URL Redirects APl Collection• Companies• COMPARE• Contacts> CRM Obiects• CRM Owners> CRM Pipelines› Dealsv EnqagementsM OLD ENGAGEMENTSGet list meetinasPOST search modified comnaniesPOST search tasksGET read cal> POST search callsGET list callspost meetinas scheduledGET get meetingPosT aet link to task> Post Greate Contact with AccociationHubspot• Iournal & wehhoooks v/A©Auth• Pronerties |> Tickets• Ulsefule• WebhooksDescriotionNo environmentvShareCookiesBulk Edit .contactcompanyValue# History~) Send + Get a successful responseo Send + Visualize response# Send + Write testsCAMIDONMCNTeSPECS>FLOWS@ Connect Git = Concole 5.) TerminaGlobals Vault Tools?000...
|
NULL
|
-9074941149962191309
|
NULL
|
visual_change
|
ocr
|
NULL
|
40100% C7Mon 20 Apr 14:19:46VIewWindowmelpXx Hubsp 40100% C7Mon 20 Apr 14:19:46VIewWindowmelpXx Hubspot vYour team is now on the Free plan with 1 admin. You retain editing access and other members are read-only. View team permissions to see who can edit, or upgrade to restore collaborationPOST search dealsPOST Read a batch of obiec • POST Read a batch of assoc •Engagements › read call{{baseUrl)} /crm/v3/objects/deal/287386441?associations=contact&associations=companyE Docs Params • Authorization • Headers 8 Body Scripts SettingsQuery Paramsassociationsv associationsGET Read• SearchGET ReadGET read calllGET Get Engagement (v1PATCH httos://api.hubapi.com# SaveV COLLECTIONS• Associations V4|• CMS - URL Redirects APl Collection• Companies• COMPARE• Contacts> CRM Obiects• CRM Owners> CRM Pipelines› Dealsv EnqagementsM OLD ENGAGEMENTSGet list meetinasPOST search modified comnaniesPOST search tasksGET read cal> POST search callsGET list callspost meetinas scheduledGET get meetingPosT aet link to task> Post Greate Contact with AccociationHubspot• Iournal & wehhoooks v/A©Auth• Pronerties |> Tickets• Ulsefule• WebhooksDescriotionNo environmentvShareCookiesBulk Edit .contactcompanyValue# History~) Send + Get a successful responseo Send + Visualize response# Send + Write testsCAMIDONMCNTeSPECS>FLOWS@ Connect Git = Concole 5.) TerminaGlobals Vault Tools?000...
|
NULL
|
|
11538
|
233
|
13
|
2026-04-14T09:46:48.402862+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-14/1776 /Users/lukas/.screenpipe/data/data/2026-04-14/1776160008402_m2.jpg...
|
Dia
|
Work: CircleCI
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
FileEditViewTabsBookmarksHistoryExtensionsWindowHe FileEditViewTabsBookmarksHistoryExtensionsWindowHelpSaved searchesapp.staging.jiminny.com/ondemand?topic_id[]=e02f0932-cb76-41b6-ac4f-6b8db1392146&include_internal_conversations=1&sequence_number=4You are currently impersonating Nikolay Yankov <)14 activitiesSort by: Most recent# Add RecordingFA: Get NotifiedPlatform Sprint 1 Q2 - Platform TeaJY-20543 add AJ reports User piloleamSearch teamsiTopics: Competitors X|Show internal and external activities: Show internal only xSave Search @Clear alHostIActivityZ Configure SSH access to multipleHost@ Console Home | Console Home | usSearch team membersiSecurityGroup | EC2 |us-east-210Mise searen as DarticloanuJY-20543 add AJ reports User pilcSRD-6779 | JY-20632 | Unable toJy 19798 evaluation for ai activity8 Jiminny7 Ask Jiminny test report - 8 Aor 201Service-Desk - Queues - PlatformJY-20543 add AJ reports User pilc(x) Configure SSH access to multipleCa CloudWatch | us-east-2New TabCa CloudWatch | us-east-2+ New TabParuicipantsearcn leam membersCustomer ®Customer or SubjectTranscript ©• Search transcript |Said byMnyonePeriodAll timeTopicsCompetitors *)Activity typeSearch activity typesDuration ©Min (minutes)Max (minutes)Al call scoreSelect Al call scoreMulomaled call scoreSelect automated calliCoaching scoreSelect coaching scoreCoach ®Search coaches.Stage at callSearch stagesCurrent stageSearch stagesJiminny MSalestorce App IrainingJiminny MBekkie/ Jules - renewals and Contract..Jiminny MHandover training part 1Unknown Customer Md 6 Deal Insights playbook d $Exclaimer MExclaimer handoverUnknown Customer MHow to run an Intro Call ATom Lavery at Plamen enterprise MJiminny & - All-hands meeting i%Unknown Customer WAll -hands Jan 9thUnknown Customer MNotetaker added on 11-28-23 @ 04:01Unknown Customer (played MTraining 8 - Jiminny Apo for Salesfor….Unknown Customer MAll-hands - Thank Yous, What Went, L...Unknown Customer (played MAll-hands - Nov 1thiUnknown Customer 1All Hands - October 31st ©Unknown Customer MDeal Insights Referesher/Training w/T...JY-18909-automated-reports-ask-iiminny = 869720ContactActivityTypeCurrent StagestatsTomLaveryUnlikelv ToRenew$42,12600 D 4I 1D3D 2$0 828DO DO$1 %0•0 D 4{0&2D0D62 1DODI{003DUD3wuo4Đ0 D4$0 82D0D1*1 ∞ 2Đ0 D 11DurationDate4/m44m34m17m43m28m51m25m39m41m11/0/2024.2:28 PMI25/06/2024, 3:09 PM10/06/2024, 12:02 PM18/05/9014. 19.96 PMI13/03/2024, 11:28 AM26/02 9024.2:30 PM11O/U1/Z024.114SAVI09/01/2024, 11:45 AM28/11/2023, 11:30 AM22/11/2023, 11:56 AM14/11/2023, 11:27 AM0/119O/3.11:42 AMI31/10/2023, 11:35 AM25/10/2023, 11:46 AMSupport Daily • in 2 h 14 mTue 14 Aor 17:46:48Q InspectorFiter URLsConsoleD DebuggerT Network{) Style Editor( PerformanceELE MemoryE Storage© 99+Disable CacheNo Throttling + 50FllMLeSSrontsImagesMediaWsotherStatusMelnocDomain2002001200200200200200(200200200200206200200200200200200200200200200200200200200200200200200200200200200200200200200(2002002062001200204200200200204200POSTPOSIPOSTPosIPOSTPosIPOSTPOSTPOSTPOSTPOSIPOSTPOSTPOSTPosIPOSTPostPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTOPTIO...POSTPOSTPOSTOPTIO..POSTPOST153 requestsInitiatorTasoonxamoa00nn-s0unae150-01900020-301FX1TypeAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3c +a xhrAr.logr-in.comla=oonxarolatrorm-stacinear=o-0150002o-3em-/ XhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhr8r.logr-in.comrasoonxatoattorm-stacingxr=0-01908028-3ett- xhnA r.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comira=ponxat/plattorm-staging&r=6-019d8b28-3eft-/ xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrArlogr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrjsonjson8r.logr-in.comifa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrjsonAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhr8r.logr-in.comifa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrjsonAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrIsonAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrA r.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comYasoonxaf/o atform-stacina&r=6-019d8b28-3eff-7 xhnAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 [EMAIL]-stacingxr=0-01908028-3ett- xhnAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhr8r.logr-in.comifa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrjsonAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b62-66a4- xhrAr.logr-in.comMa=nonxaflnlatform-stacina?r=6-019d8062-66a4- xhnnlainA app.stagingji...authenticateA xhrAr.logr-in.comYasoonxaf/o atform-stacina&r=6-019d8028-3eff-7 xhnAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b62-6( •a xhr8r.logr-in.comifa=ponxaf/platform-staging&r=6-019d8b62-66a4- xhroainAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b62-66a4- xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b62-66a4- xhrIson246.67 KB 3,78 MB transferredFinish: 41.75 min DOMContentLoaded: 42 msload: 195 msTransferred2.43 KD2.48 kB2.48 kB2.74 kB2.74 Kb2.74 kB2.73 kB273 kB2.73 kB2.48 kB2.43 KB2.49 kB2.48 KB2.43 kB2.49 kB2.62 kB2.31 kB249 KB2.48 kB2.43 kB2.48 kB2.48 kB2.43 kB2.48 kB1.48 KB2.43 kB2.67 kB2.85 kB2.85 kB295 K2.86 kB2.83 kB2.83 kB2.83 kB2.83 k:2.85 kB285Kb2.83 kB282K2.83 kB2.83 kB985 k1.74 kB75233.12 kB1.93 k:12R MEOBOB0BOBOBOBOBOB3.04 kB96 BOBOB2.84 kB2.48 kBOB...
|
NULL
|
-9074854295171544117
|
NULL
|
click
|
ocr
|
NULL
|
FileEditViewTabsBookmarksHistoryExtensionsWindowHe FileEditViewTabsBookmarksHistoryExtensionsWindowHelpSaved searchesapp.staging.jiminny.com/ondemand?topic_id[]=e02f0932-cb76-41b6-ac4f-6b8db1392146&include_internal_conversations=1&sequence_number=4You are currently impersonating Nikolay Yankov <)14 activitiesSort by: Most recent# Add RecordingFA: Get NotifiedPlatform Sprint 1 Q2 - Platform TeaJY-20543 add AJ reports User piloleamSearch teamsiTopics: Competitors X|Show internal and external activities: Show internal only xSave Search @Clear alHostIActivityZ Configure SSH access to multipleHost@ Console Home | Console Home | usSearch team membersiSecurityGroup | EC2 |us-east-210Mise searen as DarticloanuJY-20543 add AJ reports User pilcSRD-6779 | JY-20632 | Unable toJy 19798 evaluation for ai activity8 Jiminny7 Ask Jiminny test report - 8 Aor 201Service-Desk - Queues - PlatformJY-20543 add AJ reports User pilc(x) Configure SSH access to multipleCa CloudWatch | us-east-2New TabCa CloudWatch | us-east-2+ New TabParuicipantsearcn leam membersCustomer ®Customer or SubjectTranscript ©• Search transcript |Said byMnyonePeriodAll timeTopicsCompetitors *)Activity typeSearch activity typesDuration ©Min (minutes)Max (minutes)Al call scoreSelect Al call scoreMulomaled call scoreSelect automated calliCoaching scoreSelect coaching scoreCoach ®Search coaches.Stage at callSearch stagesCurrent stageSearch stagesJiminny MSalestorce App IrainingJiminny MBekkie/ Jules - renewals and Contract..Jiminny MHandover training part 1Unknown Customer Md 6 Deal Insights playbook d $Exclaimer MExclaimer handoverUnknown Customer MHow to run an Intro Call ATom Lavery at Plamen enterprise MJiminny & - All-hands meeting i%Unknown Customer WAll -hands Jan 9thUnknown Customer MNotetaker added on 11-28-23 @ 04:01Unknown Customer (played MTraining 8 - Jiminny Apo for Salesfor….Unknown Customer MAll-hands - Thank Yous, What Went, L...Unknown Customer (played MAll-hands - Nov 1thiUnknown Customer 1All Hands - October 31st ©Unknown Customer MDeal Insights Referesher/Training w/T...JY-18909-automated-reports-ask-iiminny = 869720ContactActivityTypeCurrent StagestatsTomLaveryUnlikelv ToRenew$42,12600 D 4I 1D3D 2$0 828DO DO$1 %0•0 D 4{0&2D0D62 1DODI{003DUD3wuo4Đ0 D4$0 82D0D1*1 ∞ 2Đ0 D 11DurationDate4/m44m34m17m43m28m51m25m39m41m11/0/2024.2:28 PMI25/06/2024, 3:09 PM10/06/2024, 12:02 PM18/05/9014. 19.96 PMI13/03/2024, 11:28 AM26/02 9024.2:30 PM11O/U1/Z024.114SAVI09/01/2024, 11:45 AM28/11/2023, 11:30 AM22/11/2023, 11:56 AM14/11/2023, 11:27 AM0/119O/3.11:42 AMI31/10/2023, 11:35 AM25/10/2023, 11:46 AMSupport Daily • in 2 h 14 mTue 14 Aor 17:46:48Q InspectorFiter URLsConsoleD DebuggerT Network{) Style Editor( PerformanceELE MemoryE Storage© 99+Disable CacheNo Throttling + 50FllMLeSSrontsImagesMediaWsotherStatusMelnocDomain2002001200200200200200(200200200200206200200200200200200200200200200200200200200200200200200200200200200200200200200(2002002062001200204200200200204200POSTPOSIPOSTPosIPOSTPosIPOSTPOSTPOSTPOSTPOSIPOSTPOSTPOSTPosIPOSTPostPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTPOSTOPTIO...POSTPOSTPOSTOPTIO..POSTPOST153 requestsInitiatorTasoonxamoa00nn-s0unae150-01900020-301FX1TypeAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3c +a xhrAr.logr-in.comla=oonxarolatrorm-stacinear=o-0150002o-3em-/ XhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhr8r.logr-in.comrasoonxatoattorm-stacingxr=0-01908028-3ett- xhnA r.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comira=ponxat/plattorm-staging&r=6-019d8b28-3eft-/ xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrArlogr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrjsonjson8r.logr-in.comifa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrjsonAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhr8r.logr-in.comifa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrjsonAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrIsonAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrA r.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comYasoonxaf/o atform-stacina&r=6-019d8b28-3eff-7 xhnAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 [EMAIL]-stacingxr=0-01908028-3ett- xhnAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhr8r.logr-in.comifa=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrjsonAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b28-3eff-7 xhrAr.logr-in.comiPa=ponxaf/platform-staging&r=6-019d8b62-66a4- xhrAr.logr-in.comMa=nonxaflnlatform-stacina?r=6-019d8062-66a4- xhnnlainA app.stagingji...authenticateA xhrAr.logr-in.comYasoonxaf/o atform-stacina&r=6-019d8028-3eff-7 xhnAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b62-6( •a xhr8r.logr-in.comifa=ponxaf/platform-staging&r=6-019d8b62-66a4- xhroainAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b62-66a4- xhrAr.logr-in.comi?a=ponxaf/platform-staging&r=6-019d8b62-66a4- xhrIson246.67 KB 3,78 MB transferredFinish: 41.75 min DOMContentLoaded: 42 msload: 195 msTransferred2.43 KD2.48 kB2.48 kB2.74 kB2.74 Kb2.74 kB2.73 kB273 kB2.73 kB2.48 kB2.43 KB2.49 kB2.48 KB2.43 kB2.49 kB2.62 kB2.31 kB249 KB2.48 kB2.43 kB2.48 kB2.48 kB2.43 kB2.48 kB1.48 KB2.43 kB2.67 kB2.85 kB2.85 kB295 K2.86 kB2.83 kB2.83 kB2.83 kB2.83 k:2.85 kB285Kb2.83 kB282K2.83 kB2.83 kB985 k1.74 kB75233.12 kB1.93 k:12R MEOBOB0BOBOBOBOBOB3.04 kB96 BOBOB2.84 kB2.48 kBOB...
|
11535
|
|
23022
|
497
|
41
|
2026-04-15T10:57:46.818258+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-15/1776 /Users/lukas/.screenpipe/data/data/2026-04-15/1776250666818_m2.jpg...
|
Boosteroid
|
Boosteroid
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
[CREDIT_CARD]/125VVImperial Age--Warning: You are [CREDIT_CARD]/125VVImperial Age--Warning: You are being attacked byPlayer 3 Bird Jaguar!!!----Longbowman Created-Right-click to attack this unit!Trebuchetkovaliklnkas (Britons)Unpacking 31%TrebuchetT 1/150Q 16A4/sU7 Basil the Macedonian: 10340/10340Ф.NV8 Ashikaga Takauji: 9419/9419Anccu Hualloc: 8839/8839 E IV1 kovaliklukas: 8144/81443 Bird Jaguar: 7356/7356 @IV5 Honorius: 6703/67034 Siddhraj Jaisingh: 6060/60606 Mindaugas: 3838/3838...
|
NULL
|
-9074842284007042556
|
NULL
|
click
|
ocr
|
NULL
|
[CREDIT_CARD]/125VVImperial Age--Warning: You are [CREDIT_CARD]/125VVImperial Age--Warning: You are being attacked byPlayer 3 Bird Jaguar!!!----Longbowman Created-Right-click to attack this unit!Trebuchetkovaliklnkas (Britons)Unpacking 31%TrebuchetT 1/150Q 16A4/sU7 Basil the Macedonian: 10340/10340Ф.NV8 Ashikaga Takauji: 9419/9419Anccu Hualloc: 8839/8839 E IV1 kovaliklukas: 8144/81443 Bird Jaguar: 7356/7356 @IV5 Honorius: 6703/67034 Siddhraj Jaisingh: 6060/60606 Mindaugas: 3838/3838...
|
NULL
|
|
51929
|
1123
|
29
|
2026-04-20T06:27:36.330269+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776666456330_m2.jpg...
|
Firefox
|
Userpilot | Events — Work
|
True
|
run.userpilot.io/events/overview
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
[JY-18909] [Part2] Automated reports with Ask Jimi [JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
[JY-20543] AJ Reports > Tracking - Jira
[JY-20543] AJ Reports > Tracking - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
New Tab
New Tab
Product Growth Platform | Userpilot
Product Growth Platform | Userpilot
Userpilot | Events
Userpilot | Events
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Close bookmarks (⌘B)
Bookmarks
Bookmarks
Close sidebar
Search bookmarks
Userpilot
Dashboards
Dashboards
People
People
Data
Data
Analytics
Analytics
Sessions
Sessions
Workflows
Workflows
Engagement
Engagement
Feedback
Feedback
Search engagement, feedback, reports, users and more
⌘K
Notifications
Help
Settings
Jiminny
Production
Events
Events
Create Event
Create Event
Overview
Overview
Explore raw events
Explore raw events
Visual Labeler
Visual Labeler
Type
Type Tracked events
Tracked events
Segment
Segment All users
All users
Company
Company All companies
All companies
Page
Page All pages
All pages
Category
Category All categories
All categories
Time period
Time period Last 90 days
Last 90 days
Active users
8,121
12.6%
Active companies
344
5.85%
Total events occurred
2,875,638
24.31%
Avg. occurrences per user
456
12.59%
Overview
Overview
:rmg:
D
:rmi:
W
:rmk:
M
:rmm:
:rmo:
Last 90 days
Last 90 days
Previous Period
Previous Period
Event occurrence by
user
Most occurred Open
Most occurred
Open
Events
Search...
Status All statuses Open
Status
All statuses
Open
Selection
Name
Status
Unique Users
Unique Companies
Total occurrences
Avg. Occurrences per user
Last Occurred
Created Time
Created By
Last Updated
Last Updated By
Press Space to toggle row selection (unchecked)
:rpd:
Automated report generated
Press Space to toggle row selection (unchecked)
:rph:
Watched Notetaker Video
Press Space to toggle row selection (unchecked)
:rpl:
Watch Later Button - Notetaker Video
Press Space to toggle row selection (unchecked)
:rpp:
Shared-activity
Press Space to toggle row selection (unchecked)
:rpt:
Notification-provider-enabled
Press Space to toggle row selection (unchecked)
:rq3:
Logged-activity
Press Space to toggle row selection (unchecked)
:rq7:
Dialed-outbound
Press Space to toggle row selection (unchecked)
:rqb:
Dialed-inbound
Press Space to toggle row selection (unchecked)
:rqf:
Held-conference
Draft
0
0
0
0
-
April 9, 2026
Adelina Petrova
April 9, 2026
Adelina Petrova
Active
179
83
179
1
April 17, 2026
May 7, 2024
Imported with SDK
April 15, 2026
Imported with SDK
Active
423
134
423
1
April 17, 2026
May 3, 2024
Deleted Member
April 18, 2026
Deleted Member
Active
309
104
984
3
April 19, 2026
April 22, 2024
Imported with SDK
April 20, 2026
Imported with SDK
Active
19
19...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira","depth":4,"bounds":{"left":0.0018284575,"top":0.0518755,"width":0.07596409,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira","depth":4,"bounds":{"left":0.0,"top":0.09497207,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.10614525,"width":0.10106383,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.12769353,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.13886672,"width":0.19963431,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.16041501,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.17158818,"width":0.15525267,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20543] AJ Reports > Tracking - Jira","depth":4,"bounds":{"left":0.0,"top":0.19313647,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20543] AJ Reports > Tracking - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.20430966,"width":0.06981383,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira","depth":4,"bounds":{"left":0.0,"top":0.22585794,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.23703113,"width":0.10688165,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2585794,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.2697526,"width":0.12915559,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.29130086,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.30247405,"width":0.014960106,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Product Growth Platform | Userpilot","depth":4,"bounds":{"left":0.0,"top":0.32402235,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Product Growth Platform | Userpilot","depth":5,"bounds":{"left":0.013297873,"top":0.33519554,"width":0.06200133,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Userpilot | Events","depth":4,"bounds":{"left":0.0,"top":0.3567438,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Userpilot | Events","depth":5,"bounds":{"left":0.013297873,"top":0.367917,"width":0.030418882,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.3639266,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.39106146,"width":0.07413564,"height":0.025538707},"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},"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},"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},"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},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Close bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Bookmarks","depth":5,"bounds":{"left":0.083277926,"top":0.06943336,"width":0.026761968,"height":0.014764565},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bookmarks","depth":6,"bounds":{"left":0.083277926,"top":0.06943336,"width":0.026761968,"height":0.014764565},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close sidebar","depth":6,"bounds":{"left":0.1783577,"top":0.06424581,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXTextField","text":"Search bookmarks","depth":7,"bounds":{"left":0.082446806,"top":0.09976058,"width":0.107546546,"height":0.025538707},"help_text":"","role_description":"search text field","subrole":"AXSearchField","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Userpilot","depth":11,"bounds":{"left":0.19547872,"top":0.0518755,"width":0.026595745,"height":0.044692736},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Dashboards","depth":13,"bounds":{"left":0.19547872,"top":0.096568234,"width":0.026595745,"height":0.04309657},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXStaticText","text":"Dashboards","depth":16,"bounds":{"left":0.1981383,"top":0.12290503,"width":0.021276595,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"People","depth":13,"bounds":{"left":0.19547872,"top":0.14285715,"width":0.026595745,"height":0.04309657},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"People","depth":16,"bounds":{"left":0.20279256,"top":0.16919394,"width":0.011968086,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Data","depth":13,"bounds":{"left":0.19547872,"top":0.18914606,"width":0.026595745,"height":0.04309657},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Data","depth":16,"bounds":{"left":0.20462102,"top":0.21628092,"width":0.00831117,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Analytics","depth":13,"bounds":{"left":0.19547872,"top":0.23543495,"width":0.026595745,"height":0.04309657},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Analytics","depth":16,"bounds":{"left":0.20079787,"top":0.26177174,"width":0.015957447,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Sessions","depth":13,"bounds":{"left":0.19547872,"top":0.28172386,"width":0.026595745,"height":0.04309657},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sessions","depth":16,"bounds":{"left":0.20129654,"top":0.30806065,"width":0.014960106,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Workflows","depth":13,"bounds":{"left":0.19547872,"top":0.32801276,"width":0.026595745,"height":0.04309657},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Workflows","depth":16,"bounds":{"left":0.19946809,"top":0.35434955,"width":0.01861702,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Engagement","depth":13,"bounds":{"left":0.19547872,"top":0.37430167,"width":0.026595745,"height":0.04309657},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Engagement","depth":16,"bounds":{"left":0.19780585,"top":0.40063846,"width":0.021941489,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Feedback","depth":13,"bounds":{"left":0.19547872,"top":0.42059058,"width":0.026595745,"height":0.04309657},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Feedback","depth":16,"bounds":{"left":0.20046543,"top":0.44692737,"width":0.01662234,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Search engagement, feedback, reports, users and more","depth":13,"bounds":{"left":0.52676195,"top":0.066640064,"width":0.1200133,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"⌘K","depth":13,"bounds":{"left":0.6921542,"top":0.06743815,"width":0.006150266,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Notifications","depth":12,"bounds":{"left":0.9090758,"top":0.059856344,"width":0.012632979,"height":0.028731046},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Help","depth":11,"bounds":{"left":0.9243683,"top":0.059856344,"width":0.012632979,"height":0.028731046},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Settings","depth":11,"bounds":{"left":0.9396609,"top":0.059856344,"width":0.012632979,"height":0.028731046},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":15,"bounds":{"left":0.95761305,"top":0.061053474,"width":0.014461436,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Production","depth":14,"bounds":{"left":0.95761305,"top":0.07462091,"width":0.018783245,"height":0.012370312},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Events","depth":11,"bounds":{"left":0.23005319,"top":0.11093376,"width":0.67569816,"height":0.025538707},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Events","depth":12,"bounds":{"left":0.23005319,"top":0.11292897,"width":0.021941489,"height":0.021947326},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create Event","depth":12,"bounds":{"left":0.9406583,"top":0.10933759,"width":0.051529255,"height":0.028731046},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create Event","depth":14,"bounds":{"left":0.9459774,"top":0.11612131,"width":0.028590426,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Overview","depth":14,"bounds":{"left":0.23005319,"top":0.15083799,"width":0.029920213,"height":0.04030327},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Overview","depth":15,"bounds":{"left":0.23404256,"top":0.15762171,"width":0.021941489,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Explore raw events","depth":14,"bounds":{"left":0.2679521,"top":0.15083799,"width":0.05086436,"height":0.04030327},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Explore raw events","depth":15,"bounds":{"left":0.27194148,"top":0.15762171,"width":0.04288564,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Visual Labeler","depth":14,"bounds":{"left":0.32679522,"top":0.15083799,"width":0.03939495,"height":0.04030327},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Visual Labeler","depth":15,"bounds":{"left":0.3307846,"top":0.15762171,"width":0.031416222,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Type","depth":14,"bounds":{"left":0.23005319,"top":0.20351157,"width":0.010638298,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Type Tracked events","depth":14,"bounds":{"left":0.23005319,"top":0.22306465,"width":0.059840426,"height":0.028731046},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Tracked events","depth":16,"bounds":{"left":0.23454122,"top":0.22984837,"width":0.03274601,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Segment","depth":15,"bounds":{"left":0.29305187,"top":0.20351157,"width":0.019780586,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Segment All users","depth":15,"bounds":{"left":0.29305187,"top":0.22306465,"width":0.059840426,"height":0.028731046},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All users","depth":17,"bounds":{"left":0.2975399,"top":0.22984837,"width":0.018450798,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Company","depth":14,"bounds":{"left":0.35605052,"top":0.20351157,"width":0.021276595,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Company All companies","depth":14,"bounds":{"left":0.35605052,"top":0.22306465,"width":0.059840426,"height":0.028731046},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All companies","depth":16,"bounds":{"left":0.36070478,"top":0.22984837,"width":0.030086435,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Page","depth":15,"bounds":{"left":0.4192154,"top":0.20351157,"width":0.010804521,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Page All pages","depth":15,"bounds":{"left":0.4192154,"top":0.22306465,"width":0.059840426,"height":0.028731046},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All pages","depth":17,"bounds":{"left":0.42370346,"top":0.22944932,"width":0.019281914,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Category","depth":15,"bounds":{"left":0.4822141,"top":0.20351157,"width":0.020113032,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Category All categories","depth":15,"bounds":{"left":0.4822141,"top":0.22306465,"width":0.059840426,"height":0.028731046},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All categories","depth":17,"bounds":{"left":0.4867021,"top":0.22944932,"width":0.028756648,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Time period","depth":15,"bounds":{"left":0.54521275,"top":0.20351157,"width":0.026928192,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Time period Last 90 days","depth":15,"bounds":{"left":0.54521275,"top":0.22306465,"width":0.059840426,"height":0.028731046},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Last 90 days","depth":17,"bounds":{"left":0.5497008,"top":0.22944932,"width":0.026595745,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Active users","depth":14,"bounds":{"left":0.24202128,"top":0.29289705,"width":0.022273935,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"8,121","depth":14,"bounds":{"left":0.24202128,"top":0.31284916,"width":0.013630319,"height":0.017956903},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12.6%","depth":16,"bounds":{"left":0.27509972,"top":0.3140463,"width":0.013297873,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Active companies","depth":14,"bounds":{"left":0.3151596,"top":0.29289705,"width":0.032413565,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"344","depth":14,"bounds":{"left":0.3151596,"top":0.31284916,"width":0.009142287,"height":0.017956903},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"5.85%","depth":16,"bounds":{"left":0.34823802,"top":0.3140463,"width":0.013297873,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Total events occurred","depth":14,"bounds":{"left":0.3882979,"top":0.29289705,"width":0.040226065,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2,875,638","depth":14,"bounds":{"left":0.3882979,"top":0.31284916,"width":0.024268618,"height":0.017956903},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"24.31%","depth":16,"bounds":{"left":0.41871676,"top":0.3140463,"width":0.015957447,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Avg. occurrences per user","depth":14,"bounds":{"left":0.46143618,"top":0.29289705,"width":0.048038565,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"456","depth":14,"bounds":{"left":0.46143618,"top":0.31284916,"width":0.009142287,"height":0.017956903},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12.59%","depth":16,"bounds":{"left":0.49185506,"top":0.3140463,"width":0.015957447,"height":0.01556265},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Overview","depth":11,"bounds":{"left":0.23803191,"top":0.3735036,"width":0.027094414,"height":0.021548284},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Overview","depth":12,"bounds":{"left":0.23803191,"top":0.37430167,"width":0.027094414,"height":0.019952115},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":":rmg:","depth":12,"bounds":{"left":0.920379,"top":0.37071028,"width":0.012300532,"height":0.027134877},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"D","depth":15,"bounds":{"left":0.9247008,"top":0.37589785,"width":0.003656915,"height":0.016759777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":":rmi:","depth":12,"bounds":{"left":0.93234706,"top":0.37071028,"width":0.012300532,"height":0.027134877},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"W","depth":15,"bounds":{"left":0.9361702,"top":0.37589785,"width":0.004654255,"height":0.016759777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":":rmk:","depth":12,"bounds":{"left":0.94431514,"top":0.37071028,"width":0.012300532,"height":0.027134877},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"M","depth":15,"bounds":{"left":0.9481383,"top":0.37589785,"width":0.004654255,"height":0.016759777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":":rmm:","depth":12,"bounds":{"left":0.96193486,"top":0.37071028,"width":0.011303191,"height":0.027134877},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":":rmo:","depth":12,"bounds":{"left":0.9729056,"top":0.37071028,"width":0.011303191,"height":0.027134877},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Last 90 days","depth":11,"bounds":{"left":0.56648934,"top":0.6771748,"width":0.037400264,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Last 90 days","depth":13,"bounds":{"left":0.578125,"top":0.67996806,"width":0.022772606,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Previous Period","depth":11,"bounds":{"left":0.6118683,"top":0.6771748,"width":0.043882977,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Previous Period","depth":13,"bounds":{"left":0.623504,"top":0.67996806,"width":0.02925532,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Event occurrence by","depth":11,"bounds":{"left":0.23803191,"top":0.7418196,"width":0.06482713,"height":0.021947326},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"user","depth":14,"bounds":{"left":0.30452126,"top":0.7418196,"width":0.014295213,"height":0.021947326},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXComboBox","text":"Most occurred Open","depth":11,"bounds":{"left":0.9240359,"top":0.7410216,"width":0.06017287,"height":0.028731046},"value":"Most occurred Open","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Most occurred","depth":13,"bounds":{"left":0.92736036,"top":0.74860334,"width":0.043882977,"height":0.014365523},"value":"Most occurred","help_text":"","role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open","depth":14,"bounds":{"left":0.97257316,"top":0.7442139,"width":0.00930851,"height":0.022346368},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Events","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXTextField","text":"Search...","depth":13,"bounds":{"left":0.27709442,"top":1.0,"width":0.06299867,"height":-0.09696722},"help_text":"","role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXComboBox","text":"Status All statuses Open","depth":12,"value":"Status All statuses Open","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Status","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXTextField","text":"All statuses","depth":14,"value":"All statuses","help_text":"","role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Selection","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Name","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Status","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Unique Users","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Unique Companies","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Total occurrences","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Avg. Occurrences per user","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Last Occurred","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Created Time","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Created By","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Last Updated","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Last Updated By","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rpd:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Automated report generated","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rph:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Watched Notetaker Video","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rpl:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Watch Later Button - Notetaker Video","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rpp:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Shared-activity","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rpt:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Notification-provider-enabled","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rq3:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Logged-activity","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rq7:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Dialed-outbound","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rqb:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Dialed-inbound","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Press Space to toggle row selection (unchecked)","depth":17,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":":rqf:","depth":21,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Held-conference","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Draft","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 9, 2026","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adelina Petrova","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 9, 2026","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Adelina Petrova","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Active","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"179","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"83","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"179","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 17, 2026","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"May 7, 2024","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Imported with SDK","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 15, 2026","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Imported with SDK","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Active","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"423","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"134","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"423","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 17, 2026","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"May 3, 2024","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Deleted Member","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 18, 2026","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Deleted Member","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Active","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"309","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"104","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"984","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 19, 2026","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 22, 2024","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Imported with SDK","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"April 20, 2026","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Imported with SDK","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Active","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"19","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"19","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-9074776245980389960
|
-676538279483594842
|
click
|
accessibility
|
NULL
|
[JY-18909] [Part2] Automated reports with Ask Jimi [JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
[JY-20543] AJ Reports > Tracking - Jira
[JY-20543] AJ Reports > Tracking - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
New Tab
New Tab
Product Growth Platform | Userpilot
Product Growth Platform | Userpilot
Userpilot | Events
Userpilot | Events
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Close bookmarks (⌘B)
Bookmarks
Bookmarks
Close sidebar
Search bookmarks
Userpilot
Dashboards
Dashboards
People
People
Data
Data
Analytics
Analytics
Sessions
Sessions
Workflows
Workflows
Engagement
Engagement
Feedback
Feedback
Search engagement, feedback, reports, users and more
⌘K
Notifications
Help
Settings
Jiminny
Production
Events
Events
Create Event
Create Event
Overview
Overview
Explore raw events
Explore raw events
Visual Labeler
Visual Labeler
Type
Type Tracked events
Tracked events
Segment
Segment All users
All users
Company
Company All companies
All companies
Page
Page All pages
All pages
Category
Category All categories
All categories
Time period
Time period Last 90 days
Last 90 days
Active users
8,121
12.6%
Active companies
344
5.85%
Total events occurred
2,875,638
24.31%
Avg. occurrences per user
456
12.59%
Overview
Overview
:rmg:
D
:rmi:
W
:rmk:
M
:rmm:
:rmo:
Last 90 days
Last 90 days
Previous Period
Previous Period
Event occurrence by
user
Most occurred Open
Most occurred
Open
Events
Search...
Status All statuses Open
Status
All statuses
Open
Selection
Name
Status
Unique Users
Unique Companies
Total occurrences
Avg. Occurrences per user
Last Occurred
Created Time
Created By
Last Updated
Last Updated By
Press Space to toggle row selection (unchecked)
:rpd:
Automated report generated
Press Space to toggle row selection (unchecked)
:rph:
Watched Notetaker Video
Press Space to toggle row selection (unchecked)
:rpl:
Watch Later Button - Notetaker Video
Press Space to toggle row selection (unchecked)
:rpp:
Shared-activity
Press Space to toggle row selection (unchecked)
:rpt:
Notification-provider-enabled
Press Space to toggle row selection (unchecked)
:rq3:
Logged-activity
Press Space to toggle row selection (unchecked)
:rq7:
Dialed-outbound
Press Space to toggle row selection (unchecked)
:rqb:
Dialed-inbound
Press Space to toggle row selection (unchecked)
:rqf:
Held-conference
Draft
0
0
0
0
-
April 9, 2026
Adelina Petrova
April 9, 2026
Adelina Petrova
Active
179
83
179
1
April 17, 2026
May 7, 2024
Imported with SDK
April 15, 2026
Imported with SDK
Active
423
134
423
1
April 17, 2026
May 3, 2024
Deleted Member
April 18, 2026
Deleted Member
Active
309
104
984
3
April 19, 2026
April 22, 2024
Imported with SDK
April 20, 2026
Imported with SDK
Active
19
19...
|
NULL
|
|
36146
|
734
|
18
|
2026-04-16T10:23:34.203840+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-16/1776 /Users/lukas/.screenpipe/data/data/2026-04-16/1776335014203_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp(aolAPP (-zsh)₴4DOCKER• 81DEV (docker)82APP (-zsh)X3ec2-user@ip-10-30-...../public/vue-assets/assets/GridView-CJVxH4Dg.js./public/vue-assets/assets/ondemand-CBhkAD17.js../public/vue-assets/assets/CrmLink-rTdmxqkp.js./public/vue-assets/assets/liquor-tree-DbetBeVs.js./public/vue-assets/assets/DealRiskList-BnbcVBB8.js../public/vue-assets/assets/AskAnything-s720pn9E.js:/public/vue-assets/assets/lib-BPR1zwwF.js./public/vue-assets/assets/AppFormField-BgVfo6PN.js../public/vue-assets/assets/deal-view-Jn4yJ9Hz.js../public/vue-assets/assets/exports-DIyAIXcT.js../public/vue-assets/assets/playlists-DpSiCNMr.js../public/vue-assets/assets/callScoringTemplates-DQc-joSr.js../public/vue-assets/assets/_copy0bject-DzIIjTZN.js./public/vue-assets/assets/pusher-CYYPj3Hn.js./public/vue-assets/assets/onboard-DDojXW3c.js../public/vue-assets/assets/StatusBadge-BMn_k29a.js./public/vue-assets/assets/kiosk-nxpVorIV.js./public/vue-assets/assets/deal-insights-D5sbo4zZ.js../public/vue-assets/assets/ListView-D1HYjAvt.js../public/vue-assets/assets/_plugin-vue_export-helper-sSs0rPyg.js./public/vue-assets/assets/WelcomeLayout-B2BjjI5T.js:./public/vue-assets/assets/dashboard-CDcAQG1E.js../public/vue-assets/assets/emoji-input-D_ee3_TC.js../public/vue-assets/assets/sentry-h1XGLinV.js../public/vue-assets/assets/OrgSettingsLayout-1YAa0isa.js../public/vue-assets/assets/vuex.esm-bundler-CxmCn-TU.js../public/vue-assets/assets/playback-VJS8X-le.js./public/vue-assets/assets/AppButton-OYq5I1u7.js../public/vue-assets/assets/index.module-DoWLv01P.js../public/vue-assets/assets/intl-tel-input-C4VqCHzY.js../public/vue-assets/assets/team-insights-CrkL2M3g.js../public/vue-assets/assets/popper-DC--DigQ.js../public/vue-assets/assets/PhoneField-DsfvGNK0.js•/public/vue-assets/assets/live-DHZ3jGjw.js./public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-D2hx_saf.js../public/vue-assets/assets/index-DVKeaTSE.js../public/vue-assets/assets/logged-in-layout-B0d2IU06.js-zsh• 28526.60kB26.87kB27.91kB30.75kB34.35kB39.49kB39.69kB41.87kB43.21kB47.84kB48.24kB55.13kB61.28kB62.98kB63.05kB64.62kB79.57kB94.84kB115.66kB117.59kB120.68 kB128.67kB129.28kB164.28 kB176.44kB180.40kB197.96kB210.96kB218.14kB264.94kB298.53kB307.13kB343.99kB367.43kB689.63kB825.14kB1,402.47kB[plugin builtin:vite-reporter](!) Some chunks are larger than 500 kBafter minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldownOptions.output.codeSplittingto improve chunking: https://rolldown.rs/reference/Output0ptions.codeSplitting- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.• built in 29.74slukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-18909-automated-reports-ask-jiminny) $ISupport Daily - in 1h 37 m-zshgzip:10.05kBgzip:9.38kBgz1p:10.18kBgzip:9.58kB9z1p:10.60kBgz1p:14.98kBgzip:12.70kB9z1p:12.68kBgzip:14.34kBgzip:16.46kBgzip:15.06kBgzip:13.28kBgz1p:20.08kBgzip:18.89kB9z1p:21.83kBgz1p:22.94kBgzip:22.63kB9z1p:28.17kBgzip:33.76kB9z1p:38.70 kB921p:34.16kBgzip:40.04kBgz1p:36.72kBgzip:52.24 kB9z1p:56.16kBgz1p:67.85kBgzip:61.61kB9z1p:68.66kBgz1p:64.16kB9z1p:60.30kBgzip:77.20 kBgzip:103.87kBgz1p:84.90kBgzip:97.04kBgzip: 202.81kBgz1p:72.44kBgzip: 438.06kB86-zshmaр:92.74kBmap:73.94kBmap:93.18kBтар :78.74kBтар:115.18kBmap:173.20kBтар :138.34kBтар:150.73 kBmap:150.62kBmaр:294.48kBтар:153.25kBmaр:65.85kBmap:239.59kBтар :219.27kBmар:201.39kBmap:244.72kBтар :300.68kBтар :292.79kBmap:308.10kBmaр:500.60kBтар:258.56kBmaр:410.48kBmap:266.15kBтар :831.82 kBтар:623.70kBmap:836.88kBтар :680.92kBmар :3,947.49 kBmap:1,108.20kBmap:475.61kBтар:959.66kBmap:1,245.28kBmap:849.05kBтар :792.41kBmар: 3,016.64 kBmap:436.28kBmaр: 6,282.82kB100% <47O 878Thu 16 Apr 13:23:33181* Unable to acce...O x8APP...
|
NULL
|
-9074587237352905826
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp(aolAPP (-zsh)₴4DOCKER• 81DEV (docker)82APP (-zsh)X3ec2-user@ip-10-30-...../public/vue-assets/assets/GridView-CJVxH4Dg.js./public/vue-assets/assets/ondemand-CBhkAD17.js../public/vue-assets/assets/CrmLink-rTdmxqkp.js./public/vue-assets/assets/liquor-tree-DbetBeVs.js./public/vue-assets/assets/DealRiskList-BnbcVBB8.js../public/vue-assets/assets/AskAnything-s720pn9E.js:/public/vue-assets/assets/lib-BPR1zwwF.js./public/vue-assets/assets/AppFormField-BgVfo6PN.js../public/vue-assets/assets/deal-view-Jn4yJ9Hz.js../public/vue-assets/assets/exports-DIyAIXcT.js../public/vue-assets/assets/playlists-DpSiCNMr.js../public/vue-assets/assets/callScoringTemplates-DQc-joSr.js../public/vue-assets/assets/_copy0bject-DzIIjTZN.js./public/vue-assets/assets/pusher-CYYPj3Hn.js./public/vue-assets/assets/onboard-DDojXW3c.js../public/vue-assets/assets/StatusBadge-BMn_k29a.js./public/vue-assets/assets/kiosk-nxpVorIV.js./public/vue-assets/assets/deal-insights-D5sbo4zZ.js../public/vue-assets/assets/ListView-D1HYjAvt.js../public/vue-assets/assets/_plugin-vue_export-helper-sSs0rPyg.js./public/vue-assets/assets/WelcomeLayout-B2BjjI5T.js:./public/vue-assets/assets/dashboard-CDcAQG1E.js../public/vue-assets/assets/emoji-input-D_ee3_TC.js../public/vue-assets/assets/sentry-h1XGLinV.js../public/vue-assets/assets/OrgSettingsLayout-1YAa0isa.js../public/vue-assets/assets/vuex.esm-bundler-CxmCn-TU.js../public/vue-assets/assets/playback-VJS8X-le.js./public/vue-assets/assets/AppButton-OYq5I1u7.js../public/vue-assets/assets/index.module-DoWLv01P.js../public/vue-assets/assets/intl-tel-input-C4VqCHzY.js../public/vue-assets/assets/team-insights-CrkL2M3g.js../public/vue-assets/assets/popper-DC--DigQ.js../public/vue-assets/assets/PhoneField-DsfvGNK0.js•/public/vue-assets/assets/live-DHZ3jGjw.js./public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-D2hx_saf.js../public/vue-assets/assets/index-DVKeaTSE.js../public/vue-assets/assets/logged-in-layout-B0d2IU06.js-zsh• 28526.60kB26.87kB27.91kB30.75kB34.35kB39.49kB39.69kB41.87kB43.21kB47.84kB48.24kB55.13kB61.28kB62.98kB63.05kB64.62kB79.57kB94.84kB115.66kB117.59kB120.68 kB128.67kB129.28kB164.28 kB176.44kB180.40kB197.96kB210.96kB218.14kB264.94kB298.53kB307.13kB343.99kB367.43kB689.63kB825.14kB1,402.47kB[plugin builtin:vite-reporter](!) Some chunks are larger than 500 kBafter minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldownOptions.output.codeSplittingto improve chunking: https://rolldown.rs/reference/Output0ptions.codeSplitting- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.• built in 29.74slukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-18909-automated-reports-ask-jiminny) $ISupport Daily - in 1h 37 m-zshgzip:10.05kBgzip:9.38kBgz1p:10.18kBgzip:9.58kB9z1p:10.60kBgz1p:14.98kBgzip:12.70kB9z1p:12.68kBgzip:14.34kBgzip:16.46kBgzip:15.06kBgzip:13.28kBgz1p:20.08kBgzip:18.89kB9z1p:21.83kBgz1p:22.94kBgzip:22.63kB9z1p:28.17kBgzip:33.76kB9z1p:38.70 kB921p:34.16kBgzip:40.04kBgz1p:36.72kBgzip:52.24 kB9z1p:56.16kBgz1p:67.85kBgzip:61.61kB9z1p:68.66kBgz1p:64.16kB9z1p:60.30kBgzip:77.20 kBgzip:103.87kBgz1p:84.90kBgzip:97.04kBgzip: 202.81kBgz1p:72.44kBgzip: 438.06kB86-zshmaр:92.74kBmap:73.94kBmap:93.18kBтар :78.74kBтар:115.18kBmap:173.20kBтар :138.34kBтар:150.73 kBmap:150.62kBmaр:294.48kBтар:153.25kBmaр:65.85kBmap:239.59kBтар :219.27kBmар:201.39kBmap:244.72kBтар :300.68kBтар :292.79kBmap:308.10kBmaр:500.60kBтар:258.56kBmaр:410.48kBmap:266.15kBтар :831.82 kBтар:623.70kBmap:836.88kBтар :680.92kBmар :3,947.49 kBmap:1,108.20kBmap:475.61kBтар:959.66kBmap:1,245.28kBmap:849.05kBтар :792.41kBmар: 3,016.64 kBmap:436.28kBmaр: 6,282.82kB100% <47O 878Thu 16 Apr 13:23:33181* Unable to acce...O x8APP...
|
NULL
|
|
39731
|
810
|
2
|
2026-04-16T14:05:31.244660+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-16/1776 /Users/lukas/.screenpipe/data/data/2026-04-16/1776348331244_m1.jpg...
|
Finder
|
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
System Settings File EditViewWindowHelp‹$0(ah)100% System Settings File EditViewWindowHelp‹$0(ah)100% <478Thu 16 Apr 17:05:31181-zshAPP (-zsh)DOCKERO $1--sync-token <SYNC_TOKEN>API token for cloud syncDEV (-zsh)0 82• *3-zsh• *4-zsh[env: [ENV_SECRET] <SYNC_PASSWORD>Password for encrypting synced data[env: [ENV_SECRET] «SYNC_INTERVAL_SECS>Interval between sync cycles in seconds[default: 300]--sync-machine-id <SYNC_MACHINE_ID>Override the machine ID for this device--pause-on-drm-contentPause screen and audio capture when DRM content (Netflix, Disney+, etc.) is detected--api-authRequire authentication for remote API access. When enabled, non-localhost requests must include Authorization: Bearer «SCREENPIPE_API_KEY>. Localhost requests are always allowed--encrypt-secretsEncrypt secrets (API keys, OAuth tokens) at rest using the OS keychain. Creates a keychain key if one doesn't exist. Without this flag, the CLI will use an existing key (created by the desktop app) but won't create one--retention-days <RETENTION_DAYS>Local data retention in days. Old screen/audio data is auto-deleted after this period. Set to 0 to disable retention (keep data forever)[default: 14]-h, --helpPrint help (see a summary with '-h')lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ npx screenpipe®latest record --disable-audio --ignored-apps "Boosteroid"error: unexpected argument '--ignored-apps' foundtip: a similar argument exists: '--ignored-FinderUsage: screenpipe record --disable-audio--ig$IFor more information, try '--help'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.s...
|
NULL
|
-9074578368955431027
|
NULL
|
click
|
ocr
|
NULL
|
System Settings File EditViewWindowHelp‹$0(ah)100% System Settings File EditViewWindowHelp‹$0(ah)100% <478Thu 16 Apr 17:05:31181-zshAPP (-zsh)DOCKERO $1--sync-token <SYNC_TOKEN>API token for cloud syncDEV (-zsh)0 82• *3-zsh• *4-zsh[env: [ENV_SECRET] <SYNC_PASSWORD>Password for encrypting synced data[env: [ENV_SECRET] «SYNC_INTERVAL_SECS>Interval between sync cycles in seconds[default: 300]--sync-machine-id <SYNC_MACHINE_ID>Override the machine ID for this device--pause-on-drm-contentPause screen and audio capture when DRM content (Netflix, Disney+, etc.) is detected--api-authRequire authentication for remote API access. When enabled, non-localhost requests must include Authorization: Bearer «SCREENPIPE_API_KEY>. Localhost requests are always allowed--encrypt-secretsEncrypt secrets (API keys, OAuth tokens) at rest using the OS keychain. Creates a keychain key if one doesn't exist. Without this flag, the CLI will use an existing key (created by the desktop app) but won't create one--retention-days <RETENTION_DAYS>Local data retention in days. Old screen/audio data is auto-deleted after this period. Set to 0 to disable retention (keep data forever)[default: 14]-h, --helpPrint help (see a summary with '-h')lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ npx screenpipe®latest record --disable-audio --ignored-apps "Boosteroid"error: unexpected argument '--ignored-apps' foundtip: a similar argument exists: '--ignored-FinderUsage: screenpipe record --disable-audio--ig$IFor more information, try '--help'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.s...
|
39730
|
|
65045
|
1441
|
36
|
2026-04-21T11:55:30.714106+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-21/1776 /Users/lukas/.screenpipe/data/data/2026-04-21/1776772530714_m1.jpg...
|
PhpStorm
|
faVsco.js – AutomatedReportsRepository.php
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#11894 on JY-18909-automated-reports-ask-jiminny, menu","depth":5,"help_text":"Pull request #11894 exists for current branch JY-18909-automated-reports-ask-jiminny, but local branch is out of sync with remote","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,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9074439840405661818
|
-8616511666752330302
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
SlackFileEditViewGoHistoryWindowHelpSupport Daily - in 5 mA100% <78Tue 21 Apr 14:55:30•DOCKER-zsh₴82* Build full da...• 83|• *4DOCKER (docker-compose)• 85security11 DOCKER (docker-compose)21/Apr/2026:11:54:19"GET /index.php?status%5B%5D=completed&sort_by=dateHeld&sort_direction=desc&exclude%5B%5D=stats&only_recorded=1&user_id%5B%5D=158496ef-bdda-|44db-91a4-6f95ccdb53d4"500 /home/jiminny/public/index.php 1[PHONE] wwwdocker_1amp_1docker_lamp_11 '/usr/local/bin/php' 'artisan'meeting-bot: schedule-bot > */proc/1/181docker_lamp_12026-04-21 11:54:23 Running ['artisan'dialers:monitor-activities].7sDONEdocker_lamp_1, '/usr/local/bin/php' 'artisan' dialers:monitor-activities > '/proc/1/fd/1'2>&1elasticsearchI {"type": "server""timestamp": "2026-04-21T11:54:32,789Z", "level": "I"component":"o.e.m.j.JvmGcMonitorService", "cluster.name":"docker-cluster""message": "[gc](771] overhead, spent [263ms] collectinglast [1s]", "cluster.uuid": "8uhZw1CUSGyWYR_OvaKx6g","node.id": "e2ZKzgw4Q4aCf2w51jWr1docker_lamp_12026-04-21 11:54:30 Running ['artisan' jiminny:monitor-social-accountdocker_lamp_1 |" '/usr/local/bin/php' 'artisan' jiminny:monitor-social-accounts > '/docker_lamp_18S DONEdocker_lamp_11/fd/1'2>&1docker_1amp_11tches=15JSS DONEdocker_lamp_115 > '/proc/1/fd/1' 2>&1docker_lamp_16S DONEdocker_lamp_1fd/1' 2>&1docker_lamp_1ew]5S DONEdocker_lamp_1/proc/1/fd/1'2>&1docker_lamp_16s DONEdocker_lamp_1c/1/fd/1'2>&1docker_lamp_1docker_lamp_12026-04-21 11:54:37 Running ['artisan'mailbox:skip-lists:refresh]" '/usr/local/bin/php' 'artisan'mailbox:skip-lists:refresh > */proc/2026-04-21 11:54:46 Running ['artisan'mailbox:batch:process --max-ba1 '/usr/local/bin/php' 'artisan' mailbox:batch:process --max-batches=2026-04-21 11:54:51 Running ['artisan' conference:monitor:count] …..* '/usr/local/bin/php' 'artisan' conference:monitor:count > */proc/1/2026-04-21 11:54:58 Running ['artisan' activity:aircall:check-and-ren, '/usr/local/bin/php' 'artisan' activity:aircall:check-and-renew > '2026-04-21 11:55:04 Running ['artisan' track:retry-failed-downloads]l '/usr/local/bin/php' 'artisan' track:retry-failed-downloads › '/proI run_artisan_schedule: Done waiting for schedule:runView in Docker Desktopo View ConfigEnable Watch-zsh|86APP (-zsh)|• 87ec2-user@ip-...• 88-zshPROD (ssh)'do-release-upgrade' to upgrade to it.PROD*** System restart required ***Last login: Mon Apr 20 15:14:15 2026 from 212.5.153.87lukas@jiminny-prod-bastion:~$X L3 EU (ssh)New release '24.04.4 LTS' available.Run'do-release-upgrade'to upgrade to it.*** System restart required ***Last login: Mon Apr 20 15:14:23 2026 from 212.5.153.87lukas@jiminny-eu-bastion:~$T4 STAGE (-zsh)Last login: Mon Apr 20 19:48:04 on ttys002Poetry could not find a pyproject.toml file in /Users/lukas or its parentsSTAGEPoetry could not find a pyproject.toml file in /Users/lukas or its parentslukas@Lukas-Kovaliks-MacBook-Pro-Jiminny$T5 QA (-zsh)Poetry could not find a pyproject.toml file in /Users/lukas or its parentsPoetry could not find a pyproject.toml file in /Users/lukas or its parentsX T6 FE (-zsh)Poetry could not find a pyproject.toml file in /Users/lukas or its parentsFRONTENDPoetry could not find a pyproject.toml file in /Users/lukas or its parentslukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ IX 17 EXT (-zsh)Last login: Mon Apr 20 19:48:04 on ttys005Poetry could not find a pyproject.toml file in /Users/lukas or its parentsEXTENSIONPoetry could not find a pyproject.toml file in /Users/lukas or its parentsukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $...
|
NULL
|
|
65476
|
1459
|
15
|
2026-04-21T12:41:01.371449+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-21/1776 /Users/lukas/.screenpipe/data/data/2026-04-21/1776775261371_m1.jpg...
|
PhpStorm
|
faVsco.js – console [PROD]
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#11894 on JY-18909-automated-reports-ask-jiminny, menu","depth":5,"help_text":"Pull request #11894 exists for current branch JY-18909-automated-reports-ask-jiminny, but local branch is out of sync with remote","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,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9074439840405661818
|
-8616511666752330302
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
Notion CalendarEditViewWindowHelp+April 2026 Week 17EESTMon 20Tue 21Chloe Cross (Parental Leave - 256 days)Ivelina Hristova (Parental Leave - 184 days)Andrea Zlatanova (Parental Leave - 189 days)Wed 22Thu 23Week vSat 25TodayFri 24( Lauren Hudson (PTO...( Steliyan Georgiev (P…..Sun 26100% <78Tue 21 Apr 15:41:01ec2-user@ip-..• *8|-zsh181+PROD5.153.8711:0012:00UserpilotIntroduction11:30-12:3013:0014:0015:00Preparatior! Support1) Support Daily 15:0015:4116:00forRefinement15:00-16:001 Support Daily 15:00.1 Support Daily 15:00[Platform] :|Refinemen:)16:00-17:00SoS S.16:00-1117:00Al chapter17:00- 18:00Lukas/Stefka 12117:30=18:00..18:00docker_lamp_1'/usr/local/bin/php' "artisan'crm:bullhorn:ping--heartbeat > '/proc/1/fd/1' 2>&1docker_lamp_1docker_lamp_1I run_artisan_schedule: Done waiting for schedule:runView in Docker Desktopo View ConfigEnable Watch5.153.87/Users/lukas or its parents/Users/lukas or its parentsSTAGE1 Support Daily 15:00/Users/lukas or its parents/Users/lukas or its parents/Users/lukas or its parentsFRONTEND/Users/lukas or its parents17 EXT (-zsh)Last login: Mon Apr 20 19:48:04 on ttys005Poetry could not find a pyproject.toml file in /Users/lukas or its parents EXTENSIONPoetry could not find a pyproject.toml file in /Users/lukas or its parentsukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $...
|
65474
|
|
65785
|
1468
|
40
|
2026-04-21T13:03:00.351643+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-21/1776 /Users/lukas/.screenpipe/data/data/2026-04-21/1776776580351_m2.jpg...
|
PhpStorm
|
faVsco.js – SF [jiminny@localhost]
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#11894 on JY-18909-automated-reports-ask-jiminny, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12732713,"height":0.025538707},"help_text":"Pull request #11894 exists for current branch JY-18909-automated-reports-ask-jiminny, but local branch is out of sync with remote","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},"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},"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},"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},"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},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9074439840405661818
|
-8616511666752330302
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
PhostormVIewINavigarecodeFV faVsco.js@ #11894 on JY-18909-automated-reports-askProledeyE phostan.neon.distAskJiminnykeponacuviyserwice.ong= phostan-baseline.neonphpunit.xmlTe raw sal querv.sa.M+ README.mdo sonar-oroiect oropertiesE test.py<> Untitled Diagram.xmlIS vetur.config.jsM+ WEBHOOK FILTERING IMPLEMENTATIC LY/› Ilb External Librariesv = Scratches and Consolesv D Database ConsolesVASUconsole (EU)A DEAL RISKS (EU]A DI (EU]A EU (EU]vAjiminny@localhostconsole ljiminny@localhost]A DI [jiminny@localhost]HS_Jocal [jiminny@localhost]& Sr liminny@localhost4 zoho_dev [jiminny@localhost]V A PRODA concale IPRODIServices+, o,cv M DatabaseV AEU#concale 2clv A jiminny@localhost4 SF 497 msA HS_localV A PRODA console 1 s 620 msA STAGING& consoleDocker© Field.php© FieldRepository.php© AutomatedReportsSendCommand.php© TrackProviderlnstalledEvent.phpC) RequestGenerateReportJob.php© AutomatedReportResult.phc(C) AutomatedReport.phpclass AutomatedReportsRepositorvprivate function applyUserAccessScope(Builder $query, User $user): void">wherel co'automated_reports.team_id', $user->getTeamIdO)->where(function (Builder $q) use (SuserId, $groupId): void {$q->whereJsonContains ( column: 'automated_reports.recipients->users', $userId)->orWhere( column:'automated_reports.created_by', $userId)A16 ×911 A VЕ1ь147148=149— 156if ($groupId |== null) {-151 v$q->orWhere(function (Builder $sub) use ($groupId): void {- 152ssub->where columautomared reports.cype',operator: AutomatedReportsservice: : TYPE, ASK. JIMTNNY_154J'automated_reports.groups', $groupId);156157158159100% SzTue 21 Apr 16:03:00AskJiminnyReportActivityServiceTest v# console lPKOD.E laravel.logA console (EU]4 SF [iminny@localhost] xA HS_local [iminny@localhost]# report-not-generated.blade.phpCascadeReview Planhat IntearAutomated Reports RCalendar Multi-Domal+0 ..Tx: Auto vA console (STAGING]Playground vOnce you've identried a team or interest, get the user-by-user breakaown:activity_search_ia 1978activity_search_la 1979s8 jiminny ~value ^ 443f26b8 =018 A14 V2 V4 ^ VTlter "aroup1d',value '443f26b8-8512-437e-a9f9-7e75f8025c2:filter 'group_id',value'443f26b8-8512-437e-a9f9-7e75f8025c2SELECIu.id AS user_id,select * from crm confiqurations where 1d = 39u.email AS user email.c. calendar provider_id,LOWER(SUBSTRING_INDEX(c. calendar_provider_id, '@', -1)) AS calendar_domain,c•providerc. StatusFROM uSers uJ0TN calendars e ON c.user id = u.idselect sa.* from users u JOIN social_accounts sa on u.id = sa.sociable_idwhere u.team 1d = 1:SELECT * FROM social_accounts WHERE sociable_id = 1635;SELECT * FROM users WHERE 1d = 1635%AND u.deleted_at IS NULLAND C.Status = 'active'AND c.calendar provider 1d LiKE '%0%ORDER BY calendar_domain, u.name;select * from teams where id = 1;select * from users where team_id = 1;select * from team_features where team_id = 1;select * from features;Notescalendars.status = 'active' excludes deleted/hidden/refresh-required calendars. Drop that filter if you want to include disconnected ones.• Gooqle users can have multiole calendars (orimary + shared resource, so the same user may apoear with difterent calendar provider 1dAsk anything (8AL)SELECT * FROM activity_searches where id = 1982; # 1981SELECT * FROM activity_search_filters WHERE activity_search_id = 1982;—eadoClaude Opus 4.7 Medium* Get renort iis for a soecific tean© Outputi w1rowv# jiminny.sociaLaccounts xTx: Auto vД QI sociable 1d•provider_user_id! provider_user_tokenI provider_refresh_tokenI expiresM refresh token expiresproviderO stateDauth_scopeI retry_afterI created_ati undated atI provider_user_token_encryptedI provider_refresh_token_encryptedencryption_key (UUID)13681635103720096575886183875ya29.a0AeDCLZAM_rfPpVu?4q2gy2k-0UYLzYskUOnPnY-аQa5zgFqXfxR0q®Nqг21pBBdmg9ZgsfvDTt-D1Cy©w0b-7g_ZUBLmIu_KehWVE-LUKEKUM6bskCJG1N4j2N4x64TXrvXpEjUnEikW1ROb-21BaktpaYR8г7VfLJrdPCEFWHwaCgYKAX0SARESFQHGX2Mij2tBGuqMLIfKswzSapp6Lw01781//84ffJYAbCh3c3CgYIARAAGAQSNwF-L9Ir9NeWXRYanFMWzdxE8LInz_Y39CKmwL6-q0x-0qCpvAYVbmA®LmNU-SXAQ6P70Tjy7UE1732722084<null>googleconnectedhttps://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/calendar https://www.go0gleapis.com/auth/userinfo.email2024-09-10 08:16:122026-04-21 12:28:19<null><null>iny' has been restored // Rollback // Configure….. (today 14:01)W Windsurf Teams151.02LITC.Q4 spaces...
|
65784
|
|
45018
|
949
|
57
|
2026-04-17T09:17:02.783351+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776417422783_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
+FirefoxFileEditViewHistoryBookmarksProfilesToolsW +FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp→meet.google.com/xpx-omah-rknllian Kyuchukov (Presenting, annotating)BraveViewProfilesHelpError during deal CRM auto pi888 DEV SimineyPROO S0https://jiminny.sentry.io/fssues/6914839403/events/52918e1/182949e3a910a3e587d1d0c1/2end=2026-04-16T21%3A08%3APROD EU Jiminny@ My PRs• Assigned• To ReviewJiminny Prophet W...Pipelines - jminey.-OwAncinttnemIssuesEJ PROPHET-118Error during deal CRM auto populate extraction: All endpoints failed to generate a response1/orm/deal/extract-feld-valuesU8Errors & OutagesBreached MetricsWarningsUser FeedbackResolveArchiveAll EnvsvQ Filter events..Cycot996Users1001ConfigureAlertsApr 16 7.00 PM|Events v in this issueD: 52918e1f|3.hours.agoJSON@ CPython 3.14.3• prod-eu~ HighlightsACuCaTrentizonCrroLscn/deal/extract-field-values~ MessageError during deal CRN auto populate extraction: All endpoints failed to generate a responsev Breadcrumbs© Message - This event errorError during deal CRM auto populate extraction: All endpoints fasled to generate a response« app erfolA1l endpoints fasled to generate a responsePS12:17 PM | Daily - Processing‹$0(ahlSupport Daily • in 2h 43 m100% C478 • Fri 17 Apr 12:17:0240x Confiuence|v) Service-Desk - Qul6• 100%|79% prod-euruntimenamruntime96X CPython 3.114.3View all tags and feature flagsRecommendedView More EventsO CopyasJumpto: HighlightsBreadcrumbs/ Edithttp://propbet.j1n100y.eu/scn/deal/ext.rasACLALANISANT• Q 88• Fri17 Apr 12:17@ AWSD All BookmarkeEvents (totalUsers (9Od)4.2KPriority alAssigneeLast seen 2 hours agoFirst seen 7 months ago~ Seer Autofx @Have Seer1. Determine the rootcause of your issue2. Outline a pian3. Create a code fox# Start Analysistssue tracking• Gtнuоe Эira> Activityv People63 A 910viewedSimilar IssuesMerged Issuesllian KyuchukovNikolay NikolovVasil VasilevMihail MihaylovO Copyas~AOTS OZLOOJOS AMUTCAрR14022105.065 AM01CLukas Kovalik...
|
NULL
|
-9074291596289493309
|
NULL
|
click
|
ocr
|
NULL
|
+FirefoxFileEditViewHistoryBookmarksProfilesToolsW +FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp→meet.google.com/xpx-omah-rknllian Kyuchukov (Presenting, annotating)BraveViewProfilesHelpError during deal CRM auto pi888 DEV SimineyPROO S0https://jiminny.sentry.io/fssues/6914839403/events/52918e1/182949e3a910a3e587d1d0c1/2end=2026-04-16T21%3A08%3APROD EU Jiminny@ My PRs• Assigned• To ReviewJiminny Prophet W...Pipelines - jminey.-OwAncinttnemIssuesEJ PROPHET-118Error during deal CRM auto populate extraction: All endpoints failed to generate a response1/orm/deal/extract-feld-valuesU8Errors & OutagesBreached MetricsWarningsUser FeedbackResolveArchiveAll EnvsvQ Filter events..Cycot996Users1001ConfigureAlertsApr 16 7.00 PM|Events v in this issueD: 52918e1f|3.hours.agoJSON@ CPython 3.14.3• prod-eu~ HighlightsACuCaTrentizonCrroLscn/deal/extract-field-values~ MessageError during deal CRN auto populate extraction: All endpoints failed to generate a responsev Breadcrumbs© Message - This event errorError during deal CRM auto populate extraction: All endpoints fasled to generate a response« app erfolA1l endpoints fasled to generate a responsePS12:17 PM | Daily - Processing‹$0(ahlSupport Daily • in 2h 43 m100% C478 • Fri 17 Apr 12:17:0240x Confiuence|v) Service-Desk - Qul6• 100%|79% prod-euruntimenamruntime96X CPython 3.114.3View all tags and feature flagsRecommendedView More EventsO CopyasJumpto: HighlightsBreadcrumbs/ Edithttp://propbet.j1n100y.eu/scn/deal/ext.rasACLALANISANT• Q 88• Fri17 Apr 12:17@ AWSD All BookmarkeEvents (totalUsers (9Od)4.2KPriority alAssigneeLast seen 2 hours agoFirst seen 7 months ago~ Seer Autofx @Have Seer1. Determine the rootcause of your issue2. Outline a pian3. Create a code fox# Start Analysistssue tracking• Gtнuоe Эira> Activityv People63 A 910viewedSimilar IssuesMerged Issuesllian KyuchukovNikolay NikolovVasil VasilevMihail MihaylovO Copyas~AOTS OZLOOJOS AMUTCAрR14022105.065 AM01CLukas Kovalik...
|
NULL
|
|
34644
|
700
|
7
|
2026-04-16T08:56:22.864475+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-16/1776 /Users/lukas/.screenpipe/data/data/2026-04-16/1776329782864_m1.jpg...
|
Slack
|
Search - Jiminny Inc - Slack
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Results for:
promise
Give feedback
Sort: Most relevant (default)
Sort: Most relevant (default)
9 results
Lukas Kovalik
jiminny-x-integration-app
Jun 3rd, 2025 at 4:38 PM
Jun 3rd, 2025 at 4:38 PM
1.
There appears to be a recent change in the SDK OAuth mechanism. When a new client connects to the platform using Zoho, we no longer receive a
Promise
(
[URL_WITH_CREDENTIALS] Kovalik
@Lukas Kovalik
!
...
From the code and from the ref docs I see that it must return a
promise
...
Even if something was wrong internally it's still an async function
...
Do you have an example of how you are
... Show more
Jun 3rd, 2025 at 4:46:44 PM
4:46...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Jiminny Inc","depth":12,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXRadioButton","text":"Jiminny (Staging)","depth":12,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Add workspaces","depth":12,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"role_description":"text"},{"role":"AXStaticText","text":"Results for:","depth":18,"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":18,"role_description":"text"},{"role":"AXButton","text":"Give feedback","depth":18,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXComboBox","text":"Sort: Most relevant (default)","depth":18,"value":"Sort: Most relevant (default)","role_description":"combo box","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Sort: Most relevant (default)","depth":19,"role_description":"text"},{"role":"AXStaticText","text":"9 results","depth":18,"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:38 PM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:38 PM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"1.","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"There appears to be a recent change in the SDK OAuth mechanism. When a new client connects to the platform using Zoho, we no longer receive a","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"Promise","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"(","depth":22,"role_description":"text"},{"role":"AXLink","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":23,"role_description":"text"},{"role":"AXStaticText","text":") upon successful","depth":22,"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"(edited)","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"console.integration.app","depth":22,"role_description":"text"},{"role":"AXButton","text":"29 replies","depth":21,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply 10 months ago","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":22,"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Today at 11:04 AM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Today at 11:04 AM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Hi guys, we have one issue we used to have before regarding the authorisation for Zoho CRM. When the clients go through all steps and login it just returns him back to the login screen. I believe this is the reference to the","depth":22,"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Lukas Kovalik","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"There appears to be a recent change in the SDK OAuth mechanism. When a new client connects to the platform using Zoho, we no longer receive a","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"Promise","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"(","depth":24,"role_description":"text"},{"role":"AXLink","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":25,"role_description":"text"},{"role":"AXStaticText","text":") upon successful login. This functionality was working just a few weeks ago.","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"(edited)","depth":23,"role_description":"text"},{"role":"AXLink","text":"Thread in jiminny-x-integration-app","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"|","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"|","depth":23,"role_description":"text"},{"role":"AXLink","text":"View message","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View message","depth":24,"role_description":"text"},{"role":"AXButton","text":"3 replies","depth":21,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply today at 11:08 AM","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":22,"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:47 PM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:47 PM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Previously we were getting","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"Promise","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"that confirms the success login","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"Can’t find what you’re looking for?","depth":20,"role_description":"text"},{"role":"AXButton","text":"Get an overview about \"in:jiminny-x-integration-app promise\"","depth":20,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Get an overview about \"","depth":21,"role_description":"text"},{"role":"AXStaticText","text":"in:","depth":21,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":21,"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":21,"role_description":"text"},{"role":"AXStaticText","text":"\"","depth":21,"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:48 PM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:48 PM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"just the confirmation in the form of","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"Promise","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"is missing","depth":22,"role_description":"text"},{"role":"AXButton","text":"Bohdan","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:50 PM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:50 PM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Ok, let's align","depth":22,"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"part first as I'm confused there","depth":22,"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"I assume you call SDK method and it returns a","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"for all connections expect zoho?","depth":22,"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Daniil","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 5:12 PM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 5:12 PM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"I am getting a","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"with response from connections in SDK","depth":22,"role_description":"text"},{"role":"AXLink","text":"CleanShot 2025-06-03 at 10.12.22.png CleanShot 2025-06-03 at 10.12.22.png Shared by Daniil on Jun 3rd, 2025","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"CleanShot 2025-06-03 at 10.12.22.png","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"Shared by Daniil","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"on Jun 3rd, 2025","depth":24,"role_description":"text"},{"role":"AXLink","text":"Download CleanShot 2025-06-03 at 10.12.22.png","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share file: CleanShot 2025-06-03 at 10.12.22.png","depth":24,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View canvas details","depth":24,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":24,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Daniil","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 5:15 PM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 5:15 PM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"For zoho it indeed doesn’t return the","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"role_description":"text"},{"role":"AXStaticText","text":".","depth":22,"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"We’ve seen this with some other apps that break connection to the parent window. Let me check if it’s the","depth":22,"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Bohdan","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 5:12 PM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 5:12 PM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"so the","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"that you awaited is empty?","depth":22,"role_description":"text"},{"role":"AXButton","text":"Bohdan","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:42 PM","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:42 PM","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Hi,","depth":22,"role_description":"text"},{"role":"AXLink","text":"@Lukas Kovalik","depth":22,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"@Lukas Kovalik","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"!","depth":22,"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"From the code and from the ref docs I see that it must return a","depth":22,"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Even if something was wrong internally it's still an async function","depth":22,"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Do you have an example of how you are","depth":22,"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:46:44 PM","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4:46","depth":24,"role_description":"text"}]...
|
-9074208175821362895
|
-5946609381773381317
|
idle
|
hybrid
|
NULL
|
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Results for:
promise
Give feedback
Sort: Most relevant (default)
Sort: Most relevant (default)
9 results
Lukas Kovalik
jiminny-x-integration-app
Jun 3rd, 2025 at 4:38 PM
Jun 3rd, 2025 at 4:38 PM
1.
There appears to be a recent change in the SDK OAuth mechanism. When a new client connects to the platform using Zoho, we no longer receive a
Promise
(
[URL_WITH_CREDENTIALS] Kovalik
@Lukas Kovalik
!
...
From the code and from the ref docs I see that it must return a
promise
...
Even if something was wrong internally it's still an async function
...
Do you have an example of how you are
... Show more
Jun 3rd, 2025 at 4:46:44 PM
4:46
FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpallSupport Daily • in 3h 4 mLA100% C8Thu 16 Apr 11:56:22-zshDOCKERnFROM\nframes \nWHERE\n56582333s2026-04-16T11:20:43.244760ZInFROM\nframes\nWHERE\nd=3.953801125s2026-04-16T11:20:43.246740Z2026-04-16T11:20:48.600603Z2026-04-16T11:21:08.118947Z2026-04-16T11:23:17.751550Z2026-04-16T11:26:11.761391Z\nFROM\nframes\nWHERE\nd=3.593101083s2026-04-16T11:26:11.762679Z2026-04-16T11:26:15.500463Z2026-04-16T11:26:26.014153Z2026-04-16T11:31:28.731069Z\nFROM\nframes\nWHERE\nd=2.600208041s2026-04-16T11:31:28.732839Z2026-04-16T11:31:33.926759Z2026-04-16T11:31:48.403273Z2026-04-16T11:36:43.378402Z2026-04-16T11:36:48.650708Z2026-04-16T11:36:52.354970Z2026-04-16T11:36:58.181767Z2026-04-16T11:36:58.523722Z2026-04-16T11:37:20.170219Z2026-04-16T11:37:56.882597Z2026-04-16T11:42:00.309082Z\nFROM\nframes\nWHERE\nd=1.77298345852026-04-16T11:42:00.309197Z2026-04-16T11:42:03.033086Z2026-04-16111:42:07.60826622026-04-16T11:47:07.854850Z2026-04-16T11:47:09.544446Z2026-04-16T11:47:12.785911Z2026-04-16T11:50:53.949646Z2026-04-16T11:52:16.266043Z\nFROM\nframes\nWHERE\n=3.464891083s2026-04-16T11:52:16.266769Z2026-04-16T11:52:19.048528Z2026-04-16T11:52:24.793563Ztimestampdatetime('now',[EMAIL]')\nAND app_name IS NOT NULL\n* Unable to acce... O 88AND window_nameIS NOT NULL\n"rows_affected=0 rows_returned=147 elapsed=2.0WARNsqlx::query:summary="SELECT id,snapshot_path IS NOT NULL\nsnapshot_path,device_name, …"db.statement="\n\nSELECT\n id,\nsnapshot_path, \ndevice_name, \ntimestampAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n5000\n" rows_affected-0 rows_returned-118 elapseINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: found 118eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshotINFOcompaction: 40 frames, 15.6MB → 1.2MB (12.9X), 40 JPEGs deletedscreenpipe_engine::snapshot_compaction: snapshotcompaction: 76 frames, 12.6MB → 5.4MB (2.3x), 76 JPEGs deletedINFOscreenpipe_engine::event_driven_capture: content dedup:skipping capture for monitor 1 (hash=-191790730269621217,WARNsqlx::query:summary="SELECT id, snapshot_path, device_name, .."db.statement="\n\nSELECT\nid, \ntrigger=click)snapshot_path, \n device_name, \ntimestampsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n5000\n*rows_affected=0 rows_returned=114 elapseINFOscreenpipe_engine::snapshot_compaction:snapshotcompaction:found114eligibleframesINFOscreenpipe_engine::snapshot_compaction:snapshotcompaction: 32 frames, 12.1MB → 2.4MB (5.0x), 32 JPEGS deletedINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 80 frames,13.4MB → 6.4MB (2.1x),80 JPEGs deletedWARNsqlx::query:summary="SELECT id,snapshot_path, device_name,_" db.statement="\n\nSELECT\n id,\nsnapshot_path, \ndevice_name, \nsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n5000\n" rows_affected-0 rows_returned-128 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 128 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 40 frames, 11.9MB 2.6MB (4.6x), 40 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshotINFOcompaction: 86 frames,19.0MB → 6.3MB (3.0x), 86 JPEGs deletedscreenpipe_engine::event_driven_capture: content dedup:skipping capture for monitor 2 (hash=-8093524035767003282, trigger=visual_change)INFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: found 164 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 62 frames, 20.4MB 2.3MB (9.1x), 62 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 99 frames, 17.2MB → 5.3MB (3.2x), 99 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshotINFOcompaction: 1 frames,0.3MB → 0.4MB (0.8x),1 JPEGs deletedscreenpipe_engine::event_driven_capture: content dedup:skipping capture for monitor 2 Chash=-8093524035767003282,trigger=visual_change)INFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 2 (hash=-8093524035767003282, trigger=visual_change)WARNsqlx::query:summary="SELECT id, snapshot_path, device_name, -"db.statement="\n\nSELECT\n id, \nsnapshot_path,\ndevice_name, \ntimestampsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n5000\n" rows_affected-0 rows_returned-102 elapseINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: found 102 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 42 frames, 13.9MB → 3.1MB (4.5x), 42 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 57 frames, 12.6MB 6.2MB (2.0x), 57 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: found 70 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 26 frames, 8.8MB → 1.OMB (9.2x), 26 JPEGs deletedINFO screenpipe_engine::snapshot_compaction: snapshotINFOcompaction: 42 frames, 10.5MB 4.7MB (2.2x), 42 JPEGS deletedscreenpipe_engine::event_driven_capture: content dedup:skipping capture for monitor 1 (hash=-7465795543963075308, trigger=click)WARNsqlx::query:summary="SELECT id, snapshot_path, device_name,db.statement="\n\nSELECT\n id,\nsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamprows_affected=0 rows_returned=61 elapsedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 61 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 28 frames, 9.6MB → 1.4MB (6.7x), 28 JPEGs deletedINFO: 2.1N8 (6.2x), 38 JPEGS deletedscreenpipe_engine::snapshot_compaction: snapshot compaction: 31 frames, 8.9MB → 2.1MB (4.2x), 31 JPEGs deleted...
|
34642
|
|
34645
|
701
|
8
|
2026-04-16T08:56:52.564060+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-16/1776 /Users/lukas/.screenpipe/data/data/2026-04-16/1776329812564_m2.jpg...
|
Slack
|
Search - Jiminny Inc - 2 new items - Slack
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Results for:
promise
Give feedback
Sort: Most relevant (default)
Sort: Most relevant (default)
9 results
Lukas Kovalik
jiminny-x-integration-app
Jun 3rd, 2025 at 4:38 PM
Jun 3rd, 2025 at 4:38 PM
1.
There appears to be a recent change in the SDK OAuth mechanism. When a new client connects to the platform using Zoho, we no longer receive a
Promise
(
[URL_WITH_CREDENTIALS] Kovalik
@Lukas Kovalik
!
...
From the code and from the ref docs I see that it must return a
promise
...
Even if something was wrong internally it's still an async function
...
Do you have an example of how you are
... Show more
Jun 3rd, 2025 at 4:46:44 PM
4:46...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Jiminny Inc","depth":12,"bounds":{"left":0.00546875,"top":0.05486111,"width":0.0125,"height":0.022222223},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXRadioButton","text":"Jiminny (Staging)","depth":12,"bounds":{"left":0.00546875,"top":0.09097222,"width":0.0125,"height":0.022222223},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Add workspaces","depth":12,"bounds":{"left":0.00546875,"top":0.12708333,"width":0.0125,"height":0.022222223},"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.026953125,"top":0.048611112,"width":0.020703126,"height":0.047222223},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.03125,"top":0.08125,"width":0.012109375,"height":0.009027778},"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.026953125,"top":0.09583333,"width":0.020703126,"height":0.047222223},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.032421876,"top":0.12847222,"width":0.009765625,"height":0.009027778},"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.026953125,"top":0.14305556,"width":0.020703126,"height":0.047222223},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.0296875,"top":0.17569445,"width":0.015234375,"height":0.009027778},"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.026953125,"top":0.19027779,"width":0.020703126,"height":0.047222223},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.0328125,"top":0.22291666,"width":0.008984375,"height":0.009027778},"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.026953125,"top":0.2375,"width":0.020703126,"height":0.047222223},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.03203125,"top":0.2701389,"width":0.010546875,"height":0.009027778},"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.026953125,"top":0.2847222,"width":0.020703126,"height":0.047222223},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.03203125,"top":0.31736112,"width":0.010546875,"height":0.009027778},"role_description":"text"},{"role":"AXStaticText","text":"Results for:","depth":18,"bounds":{"left":0.060546875,"top":0.056944445,"width":0.03671875,"height":0.015972223},"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":18,"bounds":{"left":0.060546875,"top":0.056944445,"width":0.05859375,"height":0.034027778},"role_description":"text"},{"role":"AXButton","text":"Give feedback","depth":18,"bounds":{"left":0.1296875,"top":0.056944445,"width":0.03671875,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXComboBox","text":"Sort: Most relevant (default)","depth":18,"bounds":{"left":0.09921875,"top":0.08472222,"width":0.08164062,"height":0.019444445},"value":"Sort: Most relevant (default)","role_description":"combo box","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Sort: Most relevant (default)","depth":19,"bounds":{"left":0.10390625,"top":0.08888889,"width":0.06367187,"height":0.011111111},"role_description":"text"},{"role":"AXStaticText","text":"9 results","depth":18,"bounds":{"left":0.060546875,"top":0.11319444,"width":0.019140625,"height":0.011111111},"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.084375,"top":0.14305556,"width":0.036328126,"height":0.011111111},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.15416667,"width":0.043359376,"height":0.024305556},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:38 PM","depth":22,"bounds":{"left":0.13046876,"top":0.14375,"width":0.018359374,"height":0.035416666},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:38 PM","depth":23,"bounds":{"left":0.13046876,"top":0.14375,"width":0.018359374,"height":0.035416666},"role_description":"text"},{"role":"AXStaticText","text":"1.","depth":22,"bounds":{"left":0.084375,"top":0.18055555,"width":0.00625,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"There appears to be a recent change in the SDK OAuth mechanism. When a new client connects to the platform using Zoho, we no longer receive a","depth":22,"bounds":{"left":0.084375,"top":0.18055555,"width":0.06601562,"height":0.08958333},"role_description":"text"},{"role":"AXStaticText","text":"Promise","depth":22,"bounds":{"left":0.084375,"top":0.27222222,"width":0.020703126,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"(","depth":22,"bounds":{"left":0.084375,"top":0.2875,"width":0.001953125,"height":0.013194445},"role_description":"text"},{"role":"AXLink","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":22,"bounds":{"left":0.084375,"top":0.2875,"width":0.06914063,"height":0.059027776},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":23,"bounds":{"left":0.084375,"top":0.2875,"width":0.06914063,"height":0.059027776},"role_description":"text"},{"role":"AXStaticText","text":") upon successful","depth":22,"bounds":{"left":0.084375,"top":0.33333334,"width":0.058203124,"height":0.028472222},"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"bounds":{"left":0.11171875,"top":0.34930557,"width":0.03125,"height":0.013888889},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.084375,"top":0.36527777,"width":0.0015625,"height":0.011805556},"role_description":"text"},{"role":"AXStaticText","text":"(edited)","depth":22,"bounds":{"left":0.08554687,"top":0.36527777,"width":0.016796876,"height":0.011805556},"role_description":"text"},{"role":"AXStaticText","text":"","depth":22,"bounds":{"left":0.10195313,"top":0.36527777,"width":0.001953125,"height":0.011805556},"role_description":"text"},{"role":"AXStaticText","text":"console.integration.app","depth":22,"bounds":{"left":0.090625,"top":0.40069443,"width":0.062109374,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"29 replies","depth":21,"bounds":{"left":0.13125,"top":0.4263889,"width":0.022265624,"height":0.011805556},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply 10 months ago","depth":22,"bounds":{"left":0.15585938,"top":0.43680555,"width":0.00078125,"height":0.0013888889},"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":22,"bounds":{"left":0.15585938,"top":0.43680555,"width":0.00078125,"height":0.0013888889},"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.084375,"top":0.47013888,"width":0.036328126,"height":0.011111111},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.48125,"width":0.04765625,"height":0.024305556},"role_description":"text"},{"role":"AXLink","text":"Today at 11:04 AM","depth":22,"bounds":{"left":0.13476562,"top":0.47083333,"width":0.017578125,"height":0.035416666},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Today at 11:04 AM","depth":23,"bounds":{"left":0.13476562,"top":0.47083333,"width":0.017578125,"height":0.035416666},"role_description":"text"},{"role":"AXStaticText","text":"Hi guys, we have one issue we used to have before regarding the authorisation for Zoho CRM. When the clients go through all steps and login it just returns him back to the login screen. I believe this is the reference to the","depth":22,"bounds":{"left":0.084375,"top":0.5083333,"width":0.06875,"height":0.13541667},"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"bounds":{"left":0.0859375,"top":0.64652777,"width":0.030859375,"height":0.013888889},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.1,"top":0.6597222,"width":0.037109375,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Lukas Kovalik","depth":25,"bounds":{"left":0.1,"top":0.6611111,"width":0.035546876,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"There appears to be a recent change in the SDK OAuth mechanism. When a new client connects to the platform using Zoho, we no longer receive a","depth":24,"bounds":{"left":0.1015625,"top":0.6770833,"width":0.046875,"height":0.15069444},"role_description":"text"},{"role":"AXStaticText","text":"Promise","depth":24,"bounds":{"left":0.1015625,"top":0.8298611,"width":0.020703126,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"(","depth":24,"bounds":{"left":0.1015625,"top":0.8451389,"width":0.001953125,"height":0.013194445},"role_description":"text"},{"role":"AXLink","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":24,"bounds":{"left":0.1015625,"top":0.8451389,"width":0.046875,"height":0.08958333},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":25,"bounds":{"left":0.1015625,"top":0.8451389,"width":0.046875,"height":0.08958333},"role_description":"text"},{"role":"AXStaticText","text":") upon successful login. This functionality was working just a few weeks ago.","depth":24,"bounds":{"left":0.1015625,"top":0.9215278,"width":0.04453125,"height":0.07430556},"role_description":"text"},{"role":"AXStaticText","text":"(edited)","depth":23,"bounds":{"left":0.090625,"top":0.99583334,"width":0.016796876,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Thread in jiminny-x-integration-app","depth":23,"bounds":{"left":0.090625,"top":0.99583334,"width":0.046484374,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":24,"bounds":{"left":0.090625,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":24,"bounds":{"left":0.090625,"top":0.99583334,"width":0.046484374,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"|","depth":23,"bounds":{"left":0.12226562,"top":0.99583334,"width":0.00390625,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025","depth":23,"bounds":{"left":0.090625,"top":0.99583334,"width":0.0515625,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025","depth":24,"bounds":{"left":0.090625,"top":0.99583334,"width":0.0515625,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"|","depth":23,"bounds":{"left":0.10117187,"top":0.99583334,"width":0.00390625,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"View message","depth":23,"bounds":{"left":0.1046875,"top":0.99583334,"width":0.0296875,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View message","depth":24,"bounds":{"left":0.1046875,"top":0.99583334,"width":0.0296875,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"3 replies","depth":21,"bounds":{"left":0.109375,"top":0.99583334,"width":0.019140625,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply today at 11:08 AM","depth":22,"bounds":{"left":0.13125,"top":0.99583334,"width":0.01171875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":22,"bounds":{"left":0.13125,"top":0.99583334,"width":0.01171875,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.036328126,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.02109375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.044921875,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:47 PM","depth":22,"bounds":{"left":0.13359375,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:47 PM","depth":23,"bounds":{"left":0.13359375,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"Previously we were getting","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.06914063,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"Promise","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"that confirms the success login","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.06640625,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"Can’t find what you’re looking for?","depth":20,"bounds":{"left":0.078125,"top":0.99583334,"width":0.07929687,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"Get an overview about \"in:jiminny-x-integration-app promise\"","depth":20,"bounds":{"left":0.078125,"top":0.99583334,"width":0.08320312,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Get an overview about \"","depth":21,"bounds":{"left":0.078125,"top":0.99583334,"width":0.055078126,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"in:","depth":21,"bounds":{"left":0.1328125,"top":0.99583334,"width":0.00546875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":21,"bounds":{"left":0.078125,"top":0.99583334,"width":0.058203124,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":21,"bounds":{"left":0.1359375,"top":0.99583334,"width":0.019921875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"\"","depth":21,"bounds":{"left":0.15546875,"top":0.99583334,"width":0.00234375,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.036328126,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.02109375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.044921875,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:48 PM","depth":22,"bounds":{"left":0.13359375,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:48 PM","depth":23,"bounds":{"left":0.13359375,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"just the confirmation in the form of","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.059765626,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"Promise","depth":22,"bounds":{"left":0.11445312,"top":0.99583334,"width":0.02109375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"is missing","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.056640625,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"Bohdan","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"bounds":{"left":0.10585938,"top":0.99583334,"width":0.021484375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.043359376,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:50 PM","depth":22,"bounds":{"left":0.13164063,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:50 PM","depth":23,"bounds":{"left":0.13164063,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"Ok, let's align","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.0359375,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"bounds":{"left":0.12148438,"top":0.99583334,"width":0.0046875,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"part first as I'm confused there","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.06679688,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"bounds":{"left":0.140625,"top":0.99583334,"width":0.0046875,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"I assume you call SDK method and it returns a","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.0640625,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"for all connections expect zoho?","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.06679688,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"bounds":{"left":0.0859375,"top":0.99583334,"width":0.030859375,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Daniil","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.015625,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"bounds":{"left":0.10117187,"top":0.99583334,"width":0.02109375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.043359376,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 5:12 PM","depth":22,"bounds":{"left":0.13085938,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 5:12 PM","depth":23,"bounds":{"left":0.13085938,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"I am getting a","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.036328126,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"bounds":{"left":0.1203125,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"with response from connections in SDK","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.05078125,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"CleanShot 2025-06-03 at 10.12.22.png CleanShot 2025-06-03 at 10.12.22.png Shared by Daniil on Jun 3rd, 2025","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.072265625,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"CleanShot 2025-06-03 at 10.12.22.png","depth":24,"bounds":{"left":0.10820313,"top":0.99583334,"width":0.04375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"Shared by Daniil","depth":24,"bounds":{"left":0.10820313,"top":0.99583334,"width":0.036328126,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.14414063,"top":0.99583334,"width":0.0015625,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"on Jun 3rd, 2025","depth":24,"bounds":{"left":0.10820313,"top":0.99583334,"width":0.048046876,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Download CleanShot 2025-06-03 at 10.12.22.png","depth":24,"bounds":{"left":0.10625,"top":0.99583334,"width":0.0125,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share file: CleanShot 2025-06-03 at 10.12.22.png","depth":24,"bounds":{"left":0.11875,"top":0.99583334,"width":0.0125,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View canvas details","depth":24,"bounds":{"left":0.13125,"top":0.99583334,"width":0.0125,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":24,"bounds":{"left":0.14375,"top":0.99583334,"width":0.0125,"height":0.00069444446},"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Daniil","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.015625,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"bounds":{"left":0.10117187,"top":0.99583334,"width":0.02109375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.043359376,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 5:15 PM","depth":22,"bounds":{"left":0.13085938,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 5:15 PM","depth":23,"bounds":{"left":0.13085938,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"For zoho it indeed doesn’t return the","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.06679688,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"bounds":{"left":0.111328125,"top":0.99583334,"width":0.02109375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":".","depth":22,"bounds":{"left":0.13203125,"top":0.99583334,"width":0.0015625,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"bounds":{"left":0.13476562,"top":0.99583334,"width":0.005078125,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"We’ve seen this with some other apps that break connection to the parent window. Let me check if it’s the","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.068359375,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"bounds":{"left":0.10273437,"top":0.99583334,"width":0.03125,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Bohdan","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"bounds":{"left":0.10585938,"top":0.99583334,"width":0.021484375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.043359376,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 5:12 PM","depth":22,"bounds":{"left":0.13164063,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 5:12 PM","depth":23,"bounds":{"left":0.13164063,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"so the","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.017578125,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"bounds":{"left":0.1015625,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"that you awaited is empty?","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.060546875,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"Bohdan","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":23,"bounds":{"left":0.10585938,"top":0.99583334,"width":0.021484375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.084375,"top":0.99583334,"width":0.043359376,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:42 PM","depth":22,"bounds":{"left":0.13164063,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025 at 4:42 PM","depth":23,"bounds":{"left":0.13164063,"top":0.99583334,"width":0.01796875,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"Hi,","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.008984375,"height":0.00069444446},"role_description":"text"},{"role":"AXLink","text":"@Lukas Kovalik","depth":22,"bounds":{"left":0.09296875,"top":0.99583334,"width":0.04140625,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"@Lukas Kovalik","depth":23,"bounds":{"left":0.09375,"top":0.99583334,"width":0.03984375,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"!","depth":22,"bounds":{"left":0.13398437,"top":0.99583334,"width":0.001953125,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"bounds":{"left":0.13710937,"top":0.99583334,"width":0.0046875,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"From the code and from the ref docs I see that it must return a","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.062109374,"height":0.00069444446},"role_description":"text"},{"role":"AXStaticText","text":"promise","depth":22,"bounds":{"left":0.1203125,"top":0.99583334,"width":0.020703126,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"bounds":{"left":0.1421875,"top":0.99583334,"width":0.0046875,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Even if something was wrong internally it's still an async function","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.068359375,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"...","depth":22,"bounds":{"left":0.1234375,"top":0.99583334,"width":0.0046875,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Do you have an example of how you are","depth":22,"bounds":{"left":0.084375,"top":0.99583334,"width":0.0640625,"height":0.00069444446},"role_description":"text"},{"role":"AXButton","text":"... Show more","depth":22,"bounds":{"left":0.1046875,"top":0.99583334,"width":0.03125,"height":0.00069444446},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Jun 3rd, 2025 at 4:46:44 PM","depth":23,"bounds":{"left":0.18710938,"top":0.045138888,"width":0.009375,"height":0.00069444446},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4:46","depth":24,"bounds":{"left":0.18710938,"top":0.045138888,"width":0.009375,"height":0.00069444446},"role_description":"text"}]...
|
-9074208175821362895
|
-5946609381773381317
|
idle
|
hybrid
|
NULL
|
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Results for:
promise
Give feedback
Sort: Most relevant (default)
Sort: Most relevant (default)
9 results
Lukas Kovalik
jiminny-x-integration-app
Jun 3rd, 2025 at 4:38 PM
Jun 3rd, 2025 at 4:38 PM
1.
There appears to be a recent change in the SDK OAuth mechanism. When a new client connects to the platform using Zoho, we no longer receive a
Promise
(
[URL_WITH_CREDENTIALS] Kovalik
@Lukas Kovalik
!
...
From the code and from the ref docs I see that it must return a
promise
...
Even if something was wrong internally it's still an async function
...
Do you have an example of how you are
... Show more
Jun 3rd, 2025 at 4:46:44 PM
4:46
SlackFileEditViewHistoryWindowHelpSearch: in:#jiminny-x-integration-app promiseResults for: prom...Give feedbackThread e jiminny-x-integration-appHome= Filters (1)Sort: Most relevant (default) v© resultsDMsAchivityMoreLukas Kovalikuminny-x-uules.Zuzs au4.38 PM1. There appears to be arecent change in the SDK•Auth mechanism. Whenanew cllent connecis tothe platform using Zoho,we no longer receive aPromise([URL_WITH_CREDENTIALS] Kovalik looks like Zoho indeed prevents us from following normal flow as wenever get an event from opened windowI created a task to work on new mechanism, we will work on it soonDaniil Jun 3rd, 2025 at 7:47 PM€O elukas Kovalik for context, we can possibly work around this by passing the connectionstatus through our backend rather than getting it from the authentication window. It willnave is limiranons biiris beter than nerhing= custom.logA console [PROD]A HS_local [jiminny@localhost]Al console [EU]546550= laravel.logA SF [jiminny@localhost]A console [STAGING] XIX. AUlo vHaycround vFRUM playdooks pJoin playbook_cacegories pc1<->1..n: on p.id = pc.plavbook idwnere orrealio = ano p.acmury tvoe =event.SELECT * FROM crm_fields WHERE crm_configuration_id = 1 and object_type = 'event' ;SELECT * FROM crm_field_values WHERE crm_field_id = 4;select * from crm_layouts cl join playbook_layouts plwhere crm_configuration_id = 1 and pl.playbook_id = 175;1<-›1..n: on cl.id = pl. layout_idselect * from teams;Euee** Frur autonared redortsjoin teams t on r.teamid = t.idWHERE r. frequency = 'daily'and r.status = 1AND t.status = 'active'AND (r.exRicesat >= now() OR r.expinesat IS NULL);select * from automated_ report_results where ceRontaid IN (18, 33);select * from activity_searches where id = 10932;select * from activity_search_filters where activity_search_id = 10932;select * from automated_reports order by id desc;select * from automated_report_results order by id desc;select * from automated_report_results where ceportaid IN (37);select * from users where id IN (7160, 3248);SELECT * FROM automated_reports WHERE uvid_to_bin('18a06a75-afd2-476f-aadc-14d4057bdda2') = uuid:> 0lablf Support Daily • in 3h 4 mA100% CThu 16 Apr 11:56:52Cascade© Integration App ConMa lminny v018 A 16 X 13 ^s it somewhere in our code that I can see the connection object returned?eeduols zonocll wiurtesvonsel0. ooreocslsz02r5alooesel40 name. 40nooas15a44ut0s KeyO."createdAt":"2024-10-03T10:06:09.911Z","updatedAt":"2025-07-0s.00.51-15.101L ISbeactivalea ralse, locouni nutos. staulc.Inteeratlon.ape connecrors zonocrm/logo.png""connectorld":"64a158e7d2605720d232e07b","connectorVersion. 440.oAutncalloackonl.nuos..aot.inteeratlon.app/oautn-calloack,nasmissinerarameterstalse, nasbocumentation" lalse, nasoeTnat's the Integration App sDK making its own APl call to ap1.getmembrane.com (that's Integration App's own API domain). Let me find where theconnection oe ect is actually nanelea in our code anter tne rromise resoives.Searched integration.app for "@integration-app/sdk openNewConnection…..searcneoconnecton.clsconnecte.lconnection.connecteclopennewco.* Allps/wecsinqugratton.ap/docs/getting-startedCascade wants to fetch this URLAllow Once565566567568 v569570--571572573=574.57513 ệAsk anything (&4L)+ @ CodeClaude Sonnet 4.64 differencescurrent version\Jiminny\Jobs\Team > SyncTolntercomIntercomService $intercomService,ActivityProviderRegistry $activityProviderRegistry,TeamRepository $teamRepository,): void {return;$team = $this->team;// We don't want to sync partner accounts to Intercom.if ($team->isPartnerDefault() === false) {return,$crm = $team->getCrmConfiguration();$tier = $team->getTier();$tierTitle = $tier?->getTitle() ?? 'Unknown';// Create remote Intercom company.Winasun leams( 4 spaces...
|
NULL
|
|
12518
|
269
|
30
|
2026-04-14T11:22:47.923947+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-14/1776 /Users/lukas/.screenpipe/data/data/2026-04-14/1776165767923_m2.jpg...
|
PhpStorm
|
faVsco.js – console [STAGING]
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
16
14
13
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE id = 1;
select * from crm_layouts where crm_configuration_id = 39;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 283;
SELECT * FROM crm_fields WHERE id = 2234;
SELECT * FROM crm_field_values WHERE crm_field_id = 2234;
select * from crm_profiles where user_id = 143;
select * from record_types where crm_configuration_id = 39; # 0121K000001MHElQAO,0121K000001MHEqQAO
select * from business_processes where crm_configuration_id = 39;
# 01941000000H669AAC, 01941000000H66JAAS
select * from record_type_field_values
where record_type_id IN (24);
select * from crm_field_values where id IN (2730);
select * from crm_configurations where id = 39;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce'; #1035
select * from users where team_id = 1; # 222 group 3
SELECT * FROM activities WHERE user_id = 222 order by id desc;
select * from sidekick_settings where team_id = 1;
select * from teams where id = 1;
select * from team_features where team_id = 1;
select * from activities where crm_configuration_id = 2
and provider = 'ms-teams' and id = 608765;
SELECT * FROM activities WHERE crm_configuration_id = 2 and crm_provider_id = '59523413338';
select * from sidekick_settings where team_id = 2;
SELECT * FROM activities WHERE id = 608660;
select * from activity_summary_logs where activity_id = 608660;
select * from ai_prompts where transcription_id = 11214;
# [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('ed78a437-2804-450e-ab2f-56ab1c641346') = uuid;
# id: 608818, crm: 59628809737
SELECT * FROM activities WHERE uuid_to_bin('36b06e55-afdd-4782-8dee-c624cd0af191') = uuid;
# id: 608821, crm: 59632069252
SELECT ce.start_time, ce.end_time, a.id, a.uuid, crm_provider_id, calendar_event_id, title,
playbook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id,
scheduled_start_time, scheduled_end_time, actual_start_time, actual_end_time, a.created_at
FROM activities a
join calendar_events ce on a.calendar_event_id = ce.id
WHERE a.id IN (608818, 608821);
select * from users where team_id = 1;
select * from team_settings where team_id = 1;
select * from crm_profiles where crm_configuration_id = 39 order by user_id;
select * from team_features where team_id = 1;
select * from users where team_id = 2;
SELECT * FROM activities WHERE uuid_to_bin('ec7647e9-5225-458b-b475-f31aa2769204') = uuid; # 612639
# Preslava N. Ivanova, grou id 3
SELECT * FROM opportunities WHERE uuid_to_bin('a2928fe5-aec5-46cb-85d9-7654c89e46a6') = uuid;
select * from activities where opportunity_id = 344 and actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00';
select
a.id,
a.type,
a.scheduled_start_time,
a.actual_start_time,
a.created_at,
a.opportunity_id,
a.status
FROM activities a
WHERE opportunity_id = 344
and status IN ('completed', 'received', 'delivered')
and (
(a.actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')
OR (a.created_at between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')
OR (a.scheduled_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00'))
;
SELECT * FROM users WHERE id = 222;
SELECT * FROM crm_profiles WHERE user_id = 222;
select * from crm_layouts where crm_configuration_id = 39;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 281;
select * from group_deal_risk_types;
select * from opportunities where team_id = 1;
SELECT * FROM opportunities WHERE id = 315;
SELECT * FROM crm_field_data WHERE object_id = 315;
select * from crm_field_data where object_id = 260;
select * from generic_ai_prompts where subject_id = 315;
select * from teams; # 36, 21, 121, [EMAIL]
SELECT * FROM social_accounts WHERE sociable_id = 121 and provider = 'bullhorn';
# [PASSWORD_DOTS]
select * from teams where id = 1;
select * from crm_configurations where id = 39;
select * from users where team_id = 1;
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 1;
# 1 - 00541000004281rAAA
# 204 - 0052g000003freeAAA
# 429 - 0052g000003qGOiAAM
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce';
select * from activities where type = 'softphone'
and created_at > '2024-12-11 15:24:36' order by id desc;
select * from activity_providers where team_id = 1;
select * from activity_provider_users where activity_provider_id = 328;
select * from opportunities where crm_configuration_id = 39
AND account_id = 178 AND is_closed = false
order by created_at DESC;
select * from contacts where id = 3952;
select * from accounts where id = 178;
# [PASSWORD_DOTS]
select * from teams where id = 36;
select * from crm_configurations where id = 21;
select * from users where team_id = 36;
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 36;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 36
and sa.provider = 'bullhorn';
select * from social_accounts where id = 348;
UPDATE social_accounts SET
provider_user_token = '21442_6802599_91:41179a58-21e7-4d7c-ad58-56bb666b2f65',
provider_refresh_token = '21442_6802599_91:01c6b335-3f2a-42e4-85ff-8a08fa65fceb',
expires = 1733998131,
state = 'connected'
WHERE id = 348;
# [PASSWORD_DOTS]
select * from teams where id = 31;
select * from crm_configurations where id = 18;
select * from users where team_id = 31; # 257
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 31;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 31
and sa.provider = 'close';
select * from contacts where crm_configuration_id = 18;
# [PASSWORD_DOTS] NEPTUNE [PASSWORD_DOTS]
select * from teams;
select * from users where id IN (1030, 1035, 1052);
select * from crm_configurations;
select * from users where team_id = 65; # 257
select * from team_settings where team_id = 65; # 257
select * from invitations where team_id = 65; # 257
select * from users where email = '[EMAIL]'; # 257
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 65;
select * from crm_configurations where id = 53;
select * from accounts where crm_configuration_id = 53 order by id desc;
select * from leads where crm_configuration_id = 53 order by id desc;
select * from contacts where crm_configuration_id = 53 order by id desc;
select * from opportunities where crm_configuration_id = 53 order by id desc;
select * from crm_profiles where crm_configuration_id = 53 order by id desc;
select * from crm_fields where crm_configuration_id = 53 order by id desc;
select * from crm_field_values where crm_field_id = 3341 order by id desc;
select * from crm_layouts where crm_configuration_id = 53 order by id desc;
select * from stages where crm_configuration_id = 53 order by id desc;
select * from crm_profiles where crm_configuration_id = 13;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 65
and sa.provider = 'integration-app';
select * from contacts where crm_configuration_id = 13;
select * from social_accounts where sociable_id = 283;
SELECT * FROM opportunities WHERE crm_provider_id = '006O400000E9bzeIAB';
select * from activity_providers where team_id = 65;
SELECT * FROM activities WHERE crm_configuration_id IN (51, 52, 53);
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 65
;
# [PASSWORD_DOTS] STAGING [PASSWORD_DOTS]
SELECT * FROM teams;
SELECT * FROM teams WHERE id = 88;
SELECT * FROM teams WHERE id = 89;
select * from team_settings where team_id = 89;
SELECT * FROM users WHERE team_id = 89;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 89;
select * from users;
SELECT * FROM social_accounts WHERE sociable_id = 1761;
SELECT * FROM crm_configurations WHERE id = 70;
select * from accounts where crm_configuration_id = 70 order by id desc;
select * from leads where crm_configuration_id = 70 order by id desc;
select * from contacts where crm_configuration_id = 70 order by id desc;
select * from opportunities where crm_configuration_id = 70 order by id desc;
select * from crm_profiles where crm_configuration_id = 70 order by id desc;
select * from crm_fields where crm_configuration_id = 70 order by id desc;
select * from crm_field_values where crm_field_id = 3536 order by id desc;
select * from crm_layouts where crm_configuration_id = 70 order by id desc;
select * from stages where crm_configuration_id = 70 order by id desc;
select * from business_processes where crm_configuration_id = 70 order by id desc;
select * from business_process_stages where business_process_id = 34;
select * from contacts where id = 10468;
select * from crm_layouts where crm_configuration_id = 70;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 388;
SELECT * FROM crm_fields WHERE id IN (3533,3534,3535);
select * from activities where crm_configuration_id = 70
and (account_id IS NOT NULL or lead_id IS NOT NULL or contact_id IS NOT NULL or opportunity_id IS NOT NULL) order by id desc;
SELECT * FROM activities WHERE uuid_to_bin('2e10b60f-8a61-41c5-a3d4-28835353dc65') = uuid;
SELECT * FROM activities where crm_configuration_id = 69 ;
SELECT * FROM users WHERE email LIKE '%[EMAIL]%';
SELECT * FROM activities WHERE uuid_to_bin('5a150c93-40fc-42ec-b3bd-c1d328e09f6e') = uuid;
SELECT * FROM opportunities WHERE id = 385;
select * from participants p
join activities a on p.activity_id = a.id
where a.crm_configuration_id = 70
and (p.lead_id IS NOT NULL or p.contact_id IS NOT NULL);
SELECT * FROM participants WHERE id = 1013638;
select * from teams where id = 90;
select * from users where team_id = 90;
select * from social_accounts where social_accounts.sociable_id IN (1960,1760);
SELECT * FROM crm_profiles WHERE crm_configuration_id = 71;
select * from invitations where team_id = 90;
select * from crm_configurations where id = 71;
select * from accounts where crm_configuration_id = 71 order by id desc;
select * from leads where crm_configuration_id = 71 order by id desc;
select * from contacts where crm_configuration_id = 71 order by id desc;
select * from opportunities where crm_configuration_id = 71 order by id desc;
select * from crm_profiles where crm_configuration_id = 71 order by id desc;
select * from crm_fields where crm_configuration_id = 71 order by id desc;
select * from crm_field_values where crm_field_id = 3341 order by id desc;
select * from crm_layouts where crm_configuration_id = 71 order by id desc;
select * from stages where crm_configuration_id = 71 order by id desc;
select * from users order by secondary_email desc;
select u.id, u.email, u.status, sa.id, sa.provider_user_id from social_accounts sa
join users u on sa.sociable_id = u.id
where sa.provider = 'google' and u.email LIKE 'aneliya%';
select * from failed_jobs order by id desc;
select * from users where email = '[EMAIL]' or secondary_email = '[EMAIL]';
select * from teams;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 39;
SELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type = 'task';
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce';
# [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('c38b3895-fd0f-4b1f-9fb2-c170dba137c6') = uuid;
SELECT * FROM crm_configurations WHERE id = 70;
select * from teams where id = 1;
select * from groups where team_id = 1;
select * from users where team_id = 1;
select o.id, o.name,o.close_date, u.id, u.name, u.group_id, r.id, r.display_name, g.name, g.scope from opportunities o
join users u on o.user_id = u.id
join groups g on u.group_id = g.id
join role_user ru on u.id = ru.user_id
join roles r on ru.role_id = r.id
where o.crm_configuration_id = 39 and close_date > '2024-01-01 00:00:00';
select * from role_user where user_id = 143;
select * from roles;
select * from role_user;
select * from groups where id = 9;
select * from scope_groups where group_id = 9;
# [PASSWORD_DOTS]
select * from teams where id = 36;
select * from crm_configurations;
SELECT * FROM social_accounts WHERE sociable_id = 121;
[URL_WITH_CREDENTIALS] string
*/
protected $signature = 'automated-reports
{--report-id= : Process a specific report by ID or UUID (bypasses frequency scheduling)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process automated reports based on their frequency (weekly, monthly, quarterly).
Use --report-id to manually trigger a specific report by ID or UUID.';
public function __construct(
private readonly LoggerInterface $logger,
private readonly BusDispatcher $dispatcher,
private readonly AutomatedReportsRepository $reportRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->logger->info(self::LOG_PREFIX . ' Started');
$now = Carbon::now();
$isMonday = $now->isMonday();
$isFirstDayOfMonth = $now->day === 1;
$currentMonth = $now->month;
// Check if the current month is a quarterly month (January, April, July, October)
$isQuarterlyMonth = in_array($currentMonth, [1, 4, 7, 10], true);
$this->logger->info(self::LOG_PREFIX . ' Checking conditions', [
'isMonday' => $isMonday,
'isFirstDayOfMonth' => $isFirstDayOfMonth,
'currentMonth' => $currentMonth,
'isQuarterlyMonth' => $isQuarterlyMonth,
]);
// Process daily reports
$this->processReports(AutomatedReportsService::FREQUENCY_DAILY);
// Process weekly reports on Mondays
if ($isMonday) {
$this->processReports(AutomatedReportsService::FREQUENCY_WEEKLY);
}
// Process monthly reports on the first day of the month
if ($isFirstDayOfMonth) {
$this->processReports(AutomatedReportsService::FREQUENCY_MONTHLY);
}
// Process quarterly reports on the first day of January, April, July, and October
if ($isFirstDayOfMonth && $isQuarterlyMonth) {
$this->processReports(AutomatedReportsService::FREQUENCY_QUARTERLY);
}
$this->logger->info(self::LOG_PREFIX . ' Completed');
return 0;
}
/**
* Process reports for a specific frequency.
*
* @param string $frequency
*
* @return void
*/
private function processReports(string $frequency): void
{
$this->logger->info(self::LOG_PREFIX . " Processing $frequency reports");
$reportId = $this->option('report-id');
if ($reportId !== null) {
$reports = $this->getReportById($reportId);
} else {
// Get all enabled, not deleted reports with active teams for the specified frequency
$reports = $this->reportRepository->getActiveReportsByFrequency($frequency);
}
$this->logger->info(self::LOG_PREFIX . " Found {$reports->count()} $frequency reports to process");
/** @var AutomatedReport $report */
foreach ($reports as $report) {
$this->logger->info(self::LOG_PREFIX . ' Dispatching Generate Report job for report', [
'reportUuid' => $report->getUuid(),
'teamId' => $report->getTeamId(),
'frequency' => $report->getFrequency(),
'type' => $report->getType(),
]);
$job = $report->isAskJiminnyReport()
? new RequestGenerateAskJiminnyReportJob($report->getUuid())
: new RequestGenerateReportJob($report->getUuid());
$this->dispatcher->dispatch($job);
}
}
private function getReportById(string $reportId): Collection
{
$report = $this->reportRepository->findByIdOrUuid($reportId);
if ($report === null) {
$this->logger->warning(self::LOG_PREFIX . ' Report not found for --report-id', ['reportId' => $reportId]);
$this->warn("Report not found: {$reportId}");
return collect();
}
if (! $report->getStatus()) {
$this->logger->warning(self::LOG_PREFIX . ' Report is inactive, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
]);
$this->warn('Report is inactive — processing anyway (manual override).');
}
$team = $report->getTeam();
if ($team->getStatus() !== Team::STATUS_ACTIVE) {
$this->logger->warning(self::LOG_PREFIX . ' Team is not active, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
'teamId' => $report->getTeamId(),
'teamStatus' => $team->getStatus(),
]);
$this->warn("Team #{$report->getTeamId()} is not active — processing anyway (manual override).");
}
if ($report->isExpired()) {
$this->logger->warning(self::LOG_PREFIX . ' Report is expired, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
'expiresAt' => $report->getExpiresAt()?->toDateString(),
]);
$this->warn('Report is expired (expires_at: ' . $report->getExpiresAt()?->toDateString() . ') — processing anyway (manual override).');
}
$this->info(self::LOG_PREFIX . ' Automated report found ' . $report->getCustomName());
return collect([$report]);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.03046875,"top":0.017361112,"width":0.0453125,"height":0.022222223},"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#11894 on JY-18909-automated-reports-ask-jiminny, menu","depth":5,"bounds":{"left":0.07578125,"top":0.017361112,"width":0.14257812,"height":0.022222223},"help_text":"Pull request #11894 exists for current branch JY-18909-automated-reports-ask-jiminny","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.78515625,"top":0.017361112,"width":0.01328125,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AutomatedReportsCommandTest","depth":6,"bounds":{"left":0.803125,"top":0.017361112,"width":0.09765625,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AutomatedReportsCommandTest'","depth":6,"bounds":{"left":0.9007813,"top":0.017361112,"width":0.01328125,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AutomatedReportsCommandTest'","depth":6,"bounds":{"left":0.9140625,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9273437,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.96015626,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9734375,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9867188,"top":0.017361112,"width":0.013281226,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.5480469,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.5582031,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"bounds":{"left":0.57109374,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"bounds":{"left":0.58125,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"bounds":{"left":0.5914062,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"bounds":{"left":0.60429686,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"bounds":{"left":0.6171875,"top":0.06458333,"width":0.028515626,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"bounds":{"left":0.6484375,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"bounds":{"left":0.66132814,"top":0.06458333,"width":0.034765624,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9515625,"top":0.06458333,"width":0.033203125,"height":0.016666668},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.049609374,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"16","depth":4,"bounds":{"left":0.92890626,"top":0.08611111,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"14","depth":4,"bounds":{"left":0.94257814,"top":0.08611111,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"13","depth":4,"bounds":{"left":0.95625,"top":0.08611111,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.96953124,"top":0.08472222,"width":0.00859375,"height":0.015972223},"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.978125,"top":0.08472222,"width":0.008203125,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT * FROM teams WHERE id = 1;\n\nselect * from crm_layouts where crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 283;\nSELECT * FROM crm_fields WHERE id = 2234;\nSELECT * FROM crm_field_values WHERE crm_field_id = 2234;\n\nselect * from crm_profiles where user_id = 143;\n\nselect * from record_types where crm_configuration_id = 39; # 0121K000001MHElQAO,0121K000001MHEqQAO\nselect * from business_processes where crm_configuration_id = 39;\n# 01941000000H669AAC, 01941000000H66JAAS\n\nselect * from record_type_field_values\n where record_type_id IN (24);\n\nselect * from crm_field_values where id IN (2730);\n\nselect * from crm_configurations where id = 39;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce'; #1035\n\n\nselect * from users where team_id = 1; # 222 group 3\nSELECT * FROM activities WHERE user_id = 222 order by id desc;\nselect * from sidekick_settings where team_id = 1;\nselect * from teams where id = 1;\nselect * from team_features where team_id = 1;\n\nselect * from activities where crm_configuration_id = 2\nand provider = 'ms-teams' and id = 608765;\n\nSELECT * FROM activities WHERE crm_configuration_id = 2 and crm_provider_id = '59523413338';\n\nselect * from sidekick_settings where team_id = 2;\n\nSELECT * FROM activities WHERE id = 608660;\nselect * from activity_summary_logs where activity_id = 608660;\nselect * from ai_prompts where transcription_id = 11214;\n\n# ********************************************************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('ed78a437-2804-450e-ab2f-56ab1c641346') = uuid;\n# id: 608818, crm: 59628809737\nSELECT * FROM activities WHERE uuid_to_bin('36b06e55-afdd-4782-8dee-c624cd0af191') = uuid;\n# id: 608821, crm: 59632069252\nSELECT ce.start_time, ce.end_time, a.id, a.uuid, crm_provider_id, calendar_event_id, title,\nplaybook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id,\nscheduled_start_time, scheduled_end_time, actual_start_time, actual_end_time, a.created_at\nFROM activities a\njoin calendar_events ce on a.calendar_event_id = ce.id\nWHERE a.id IN (608818, 608821);\n\nselect * from users where team_id = 1;\nselect * from team_settings where team_id = 1;\nselect * from crm_profiles where crm_configuration_id = 39 order by user_id;\n\nselect * from team_features where team_id = 1;\n\nselect * from users where team_id = 2;\n\nSELECT * FROM activities WHERE uuid_to_bin('ec7647e9-5225-458b-b475-f31aa2769204') = uuid; # 612639\n# Preslava N. Ivanova, grou id 3\n\nSELECT * FROM opportunities WHERE uuid_to_bin('a2928fe5-aec5-46cb-85d9-7654c89e46a6') = uuid;\n\nselect * from activities where opportunity_id = 344 and actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00';\n\nselect\n a.id,\n a.type,\n a.scheduled_start_time,\n a.actual_start_time,\n a.created_at,\n a.opportunity_id,\n a.status\nFROM activities a\nWHERE opportunity_id = 344\nand status IN ('completed', 'received', 'delivered')\nand (\n (a.actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')\nOR (a.created_at between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')\nOR (a.scheduled_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00'))\n;\n\nSELECT * FROM users WHERE id = 222;\n\nSELECT * FROM crm_profiles WHERE user_id = 222;\nselect * from crm_layouts where crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 281;\n\nselect * from group_deal_risk_types;\n\nselect * from opportunities where team_id = 1;\n\nSELECT * FROM opportunities WHERE id = 315;\nSELECT * FROM crm_field_data WHERE object_id = 315;\nselect * from crm_field_data where object_id = 260;\n\nselect * from generic_ai_prompts where subject_id = 315;\n\nselect * from teams; # 36, 21, 121, james.graham@bullhorn.jiminny.com\nSELECT * FROM social_accounts WHERE sociable_id = 121 and provider = 'bullhorn';\n\n# ************************************************************************************\nselect * from teams where id = 1;\nselect * from crm_configurations where id = 39;\nselect * from users where team_id = 1;\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 1;\n# 1 - 00541000004281rAAA\n# 204 - 0052g000003freeAAA\n# 429 - 0052g000003qGOiAAM\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce';\n\nselect * from activities where type = 'softphone'\nand created_at > '2024-12-11 15:24:36' order by id desc;\n\nselect * from activity_providers where team_id = 1;\nselect * from activity_provider_users where activity_provider_id = 328;\n\nselect * from opportunities where crm_configuration_id = 39\nAND account_id = 178 AND is_closed = false\norder by created_at DESC;\n\nselect * from contacts where id = 3952;\nselect * from accounts where id = 178;\n\n# ************************************************************************************\nselect * from teams where id = 36;\nselect * from crm_configurations where id = 21;\nselect * from users where team_id = 36;\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 36;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 36\nand sa.provider = 'bullhorn';\n\nselect * from social_accounts where id = 348;\nUPDATE social_accounts SET\nprovider_user_token = '21442_6802599_91:41179a58-21e7-4d7c-ad58-56bb666b2f65',\nprovider_refresh_token = '21442_6802599_91:01c6b335-3f2a-42e4-85ff-8a08fa65fceb',\nexpires = 1733998131,\nstate = 'connected'\nWHERE id = 348;\n\n# ************************************************************************************\nselect * from teams where id = 31;\nselect * from crm_configurations where id = 18;\n\nselect * from users where team_id = 31; # 257\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 31;\n\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 31\nand sa.provider = 'close';\n\nselect * from contacts where crm_configuration_id = 18;\n\n# ********************** NEPTUNE **************************************************************\nselect * from teams;\nselect * from users where id IN (1030, 1035, 1052);\nselect * from crm_configurations;\n\nselect * from users where team_id = 65; # 257\nselect * from team_settings where team_id = 65; # 257\nselect * from invitations where team_id = 65; # 257\nselect * from users where email = 'integration-account@jiminny.com'; # 257\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 65;\n\nselect * from crm_configurations where id = 53;\nselect * from accounts where crm_configuration_id = 53 order by id desc;\nselect * from leads where crm_configuration_id = 53 order by id desc;\nselect * from contacts where crm_configuration_id = 53 order by id desc;\nselect * from opportunities where crm_configuration_id = 53 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 53 order by id desc;\nselect * from crm_fields where crm_configuration_id = 53 order by id desc;\nselect * from crm_field_values where crm_field_id = 3341 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 53 order by id desc;\nselect * from stages where crm_configuration_id = 53 order by id desc;\n\n\nselect * from crm_profiles where crm_configuration_id = 13;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 65\nand sa.provider = 'integration-app';\n\nselect * from contacts where crm_configuration_id = 13;\n\nselect * from social_accounts where sociable_id = 283;\n\nSELECT * FROM opportunities WHERE crm_provider_id = '006O400000E9bzeIAB';\n\nselect * from activity_providers where team_id = 65;\nSELECT * FROM activities WHERE crm_configuration_id IN (51, 52, 53);\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 65\n;\n\n# ***************************** STAGING ********************************************\nSELECT * FROM teams;\nSELECT * FROM teams WHERE id = 88;\nSELECT * FROM teams WHERE id = 89;\nselect * from team_settings where team_id = 89;\nSELECT * FROM users WHERE team_id = 89;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 89;\n\nselect * from users;\nSELECT * FROM social_accounts WHERE sociable_id = 1761;\nSELECT * FROM crm_configurations WHERE id = 70;\nselect * from accounts where crm_configuration_id = 70 order by id desc;\nselect * from leads where crm_configuration_id = 70 order by id desc;\nselect * from contacts where crm_configuration_id = 70 order by id desc;\nselect * from opportunities where crm_configuration_id = 70 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 70 order by id desc;\nselect * from crm_fields where crm_configuration_id = 70 order by id desc;\nselect * from crm_field_values where crm_field_id = 3536 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 70 order by id desc;\nselect * from stages where crm_configuration_id = 70 order by id desc;\nselect * from business_processes where crm_configuration_id = 70 order by id desc;\nselect * from business_process_stages where business_process_id = 34;\n\nselect * from contacts where id = 10468;\n\nselect * from crm_layouts where crm_configuration_id = 70;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 388;\nSELECT * FROM crm_fields WHERE id IN (3533,3534,3535);\n\nselect * from activities where crm_configuration_id = 70\nand (account_id IS NOT NULL or lead_id IS NOT NULL or contact_id IS NOT NULL or opportunity_id IS NOT NULL) order by id desc;\n\nSELECT * FROM activities WHERE uuid_to_bin('2e10b60f-8a61-41c5-a3d4-28835353dc65') = uuid;\nSELECT * FROM activities where crm_configuration_id = 69 ;\n\nSELECT * FROM users WHERE email LIKE '%jiminny_web_sa2@jiminny.com%';\nSELECT * FROM activities WHERE uuid_to_bin('5a150c93-40fc-42ec-b3bd-c1d328e09f6e') = uuid;\nSELECT * FROM opportunities WHERE id = 385;\n\nselect * from participants p\njoin activities a on p.activity_id = a.id\nwhere a.crm_configuration_id = 70\nand (p.lead_id IS NOT NULL or p.contact_id IS NOT NULL);\nSELECT * FROM participants WHERE id = 1013638;\n\nselect * from teams where id = 90;\nselect * from users where team_id = 90;\nselect * from social_accounts where social_accounts.sociable_id IN (1960,1760);\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 71;\nselect * from invitations where team_id = 90;\n\nselect * from crm_configurations where id = 71;\nselect * from accounts where crm_configuration_id = 71 order by id desc;\nselect * from leads where crm_configuration_id = 71 order by id desc;\nselect * from contacts where crm_configuration_id = 71 order by id desc;\nselect * from opportunities where crm_configuration_id = 71 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 71 order by id desc;\nselect * from crm_fields where crm_configuration_id = 71 order by id desc;\nselect * from crm_field_values where crm_field_id = 3341 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 71 order by id desc;\nselect * from stages where crm_configuration_id = 71 order by id desc;\n\nselect * from users order by secondary_email desc;\nselect u.id, u.email, u.status, sa.id, sa.provider_user_id from social_accounts sa\n join users u on sa.sociable_id = u.id\nwhere sa.provider = 'google' and u.email LIKE 'aneliya%';\n\nselect * from failed_jobs order by id desc;\n\nselect * from users where email = 'ben.allwright@learningpeople.co.uk' or secondary_email = 'ben.allwright@learningpeople.co.uk';\n\nselect * from teams;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 39;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type = 'task';\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce';\n\n# ************************************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('c38b3895-fd0f-4b1f-9fb2-c170dba137c6') = uuid;\nSELECT * FROM crm_configurations WHERE id = 70;\n\nselect * from teams where id = 1;\nselect * from groups where team_id = 1;\nselect * from users where team_id = 1;\n\nselect o.id, o.name,o.close_date, u.id, u.name, u.group_id, r.id, r.display_name, g.name, g.scope from opportunities o\njoin users u on o.user_id = u.id\njoin groups g on u.group_id = g.id\njoin role_user ru on u.id = ru.user_id\njoin roles r on ru.role_id = r.id\nwhere o.crm_configuration_id = 39 and close_date > '2024-01-01 00:00:00';\n\nselect * from role_user where user_id = 143;\nselect * from roles;\n\nselect * from role_user;\nselect * from groups where id = 9;\nselect * from scope_groups where group_id = 9;\n\n# ************************************************************************************\nselect * from teams where id = 36;\nselect * from crm_configurations;\nSELECT * FROM social_accounts WHERE sociable_id = 121;\n\nhttps://crmsandbox.zoho.com/crm/jiminnyw4/tab/Leads/4776201000005049105\nhttps://crmsandbox.zoho.com/crm/\n\nhttps://crm.zoho.com/crm/org3469620/tab/Leads/230045000229559080\n https://crm.zoho.com/crm/\n org3469620\n\nSELECT * FROM activities WHERE uuid_to_bin('03382d20-c8bc-48e7-a3d4-90b52fa5ceab') = uuid;\n\nselect * from users where email LIKE \"%mobile_automation_%\";\nselect * from social_accounts where sociable_id IN (2228);\nselect * from crm_profiles where user_id IN (2222,2223,2226,2227);\n\nselect * from teams order by id desc;\nSELECT * FROM users WHERE id = 2229;\nSELECT * FROM crm_profiles WHERE user_id = 2229;\nselect * from opportunities where crm_configuration_id = 88;\nselect * from crm_fields where crm_configuration_id = 88;\nselect * from crm_profiles where crm_configuration_id = 88;\n\nSELECT * FROM teams WHERE id = 1;\n\nSELECT * FROM users WHERE id = 143;\nSELECT * FROM users WHERE uuid_to_bin('fde193d3-06a2-4e1a-8895-62b94039215d') = uuid;\nSELECT * FROM teams WHERE uuid_to_bin('73385071-a756-42ae-9c73-8b53f2309467') = uuid;\n\nhttps://app.staging.jiminny.com/ondemand?\n min_duration=1\n &\n only_recorded=1\n &\n user_id%5B%5D=641f1acb-16b8-42d1-8726-df52979dad0e\n &\n sequence_number=2\n\n select * from users where team_id = 1 and email like '%stoyan%'\n\nselect * from coaching_feedbacks;\n\nselect * from teams;\nSELECT * FROM users WHERE team_id = 36;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 19\nand sa.provider = 'pipedrive';\n\nselect * from users where id = 143;\n\nSELECT * FROM users WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\nSELECT * FROM teams WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\nSELECT * FROM activity_shares WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\n\nselect * from users where team_id = 2;\nselect * from activities where crm_configuration_id = 39\nand activities.scheduled_start_time BETWEEN '2025-04-09 00:00:00' AND '2025-04-09 23:59:59'\nAND user_id = 143\norder by id desc;\n\n# ************************************************************************************\nselect * from teams where id = 142; # 2312, 126\nselect * from team_settings;\nselect * from users where team_id = 142; # 21642\nSELECT * FROM social_accounts WHERE sociable_id = 21642;\nSELECT * FROM crm_profiles cp join users u ON u.id = cp.user_id WHERE team_id = 142;\nselect * from crm_profiles where id IN (93);\nselect * from invitations;\nselect * from team_features where team_id = 1;\n\nSELECT * FROM crm_configurations WHERE id = 126;\nselect * from accounts where crm_configuration_id = 126 order by id desc;\nselect * from leads where crm_configuration_id = 126 order by id desc;\nselect * from contacts where crm_configuration_id = 126 order by id desc;\nselect * from opportunities where crm_configuration_id = 126 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 126 order by id desc;\nselect * from crm_fields where crm_configuration_id = 126 # 11060\n# and type IN ('picklist', 'status')\n# and object_type = 'task'\norder by id desc;\n# 5731,5732,5733\nselect DISTINCT crm_field_id from crm_field_values where crm_field_id IN (11151,12239,12215,12185,12175,12165,12144,12137,12127,12109,12107,12105,12103,12092,12037,12005,12003,11987,11969,11958,11951,11942,11931,11924,11921,11917,11915,11901,11893,11883,11872,11870,11868,11866,11839,11833,11821,11793,11780,11777,11769,11757,11737,11735,11656,11645,11638,11629,11618,11611,11602,11591,11584,11581,11558,11544,11543,11534,11532,11529,11527,11503,11497,11493,11488,11470,11468,11457,11455,11397,11387,11372,11363,11348,11323,11318,11309,11301,11300,11292,11290,11286,11284,11256,11252,11242,11237,11233,11219,11176,11160) order by id desc;\nselect * from crm_layouts where crm_configuration_id = 126 order by id desc;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id in (300,299,298);\nselect * from stages where crm_configuration_id = 126 order by id desc;\nselect * from business_processes where crm_configuration_id = 126 order by id desc;\nselect * from business_process_stages where business_process_id IN (76,75,74,73);\nselect * from playbooks where team_id = 142;\nselect * from playbook_layouts where playbook_id IN (108);\nSELECT * FROM playbook_categories WHERE playbook_id IN (108);\n\nselect * from teams where id = 130;\nSELECT * FROM social_accounts WHERE sociable_id = 2291;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 2\nand sa.provider = 'hubspot';\n\nSELECT * FROM activities\n WHERE crm_configuration_id = 110;\n\nselect * from teams;\nselect * from crm_configurations;\n\nSELECT * FROM activities WHERE id = 628773;\nSELECT * FROM crm_profiles WHERE user_id = 1460;\nSELECT * FROM social_accounts WHERE sociable_id = 2291;\n\nselect * from teams;\nselect ru.*, pr.*, p.* from users u join role_user ru on ru.user_id = u.id\njoin permission_role pr on pr.role_id = ru.role_id\n join permissions p on p.id = pr.permission_id\nwhere team_id = 495 and p.name IN ('dial');\n\nselect * from teams where id = 145;\nselect * from crm_configurations where id = 129;\nselect * from social_accounts where sociable_id = 2317;\nSELECT * FROM activities WHERE uuid_to_bin('8dbab184-a333-4268-ad57-fb41f8d53a9a') = uuid;\n\nselect * from teams where id = 1;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 280;\nSELECT * FROM crm_layout_entities WHERE id = 5507;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type IN ('event');\n\nselect * from teams;\nselect * from activities where crm_configuration_id = 14;\n\nSELECT * FROM social_accounts where provider = 'copper';\n\nselect * from activities where id = 628467;\nselect * from participants where activity_id = 628467;\n\nSELECT * FROM contacts WHERE id = 3969;\nSELECT * FROM accounts WHERE id = 177;\n\nSELECT * FROM activities WHERE uuid_to_bin('4eb54c77-cfa3-2bd4-84a7-9ed46a21c988') = uuid;\n\n# ********************* BH\nselect * from teams where id = 36;\nSELECT * FROM crm_configurations WHERE id = 21;\nselect * from activities where crm_configuration_id = 21 and id = 607901;\nselect * from activities where crm_configuration_id = 21;\n\nselect * roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 226;\n\nselect * from migrations order by id desc;\n\n# mercury\n# neptune\n# earth\n\nselect * from teams;\nselect * from teams where id = 19;\nselect * from teams where id = 27;\nselect * from users where team_id = 27;\nSELECT * FROM crm_configurations WHERE id = 42;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 19\nand sa.provider = 'pipedrive';\n\nselect * from activities where id = 631461;\nSELECT * FROM crm_field_values WHERE crm_field_id = 180;\n\nselect * from teams where id = 2;\nSELECT * FROM social_accounts WHERE sociable_id = 89;\n\nSELECT * FROM activities WHERE uuid_to_bin('ba0c029a-bc14-4e17-8603-64174acebcbb') = uuid; # 634273\nselect * from activity_summary_logs where activity_id = 634273;\n\nselect * from sidekick_settings where team_id = 2;\n\nselect * from teams; # 2, 2\nSELECT * FROM crm_configurations WHERE team_id = 2; # 2\nselect * from team_features where team_id = 2;\nselect * from features;\nSELECT * FROM opportunities WHERE crm_configuration_id = 2 and crm_provider_id = '51317301383';\nSELECT * FROM opportunities WHERE crm_configuration_id = 2 order by id desc;\n\nselect * from automated_reports order by id desc;\nselect * from automated_report_results order by id desc;\nselect * from users where team_id = 1 and id IN (7160, 3248);\nselect * from migrations order by id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1;\nselect * from groups g JOIN playbooks p on g.playbook_id = p.id where g.team_id = 1;\nselect * from groups where id = 565;\nselect * from playbooks where team_id = 1;\nselect * from playbooks where id = 175;\nselect * from playbook_categories where playbook_id = 175;\nselect * from users where team_id = 1;\nselect * from users where id = 7160;\nselect * from crm_profiles where user_id = 7160;\nselect * from features;\nselect\n *\n# id, uuid, type, provider, playbook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id, stage_id,\n# crm_configuration_id, crm_provider_id, transcription_id, status\nfrom activities where crm_configuration_id = 1 and type = 'conference'\n# and crm_provider_id IS NOT NULL\nand provider != 'uploader' and actual_start_time IS NOT NULL\nORDER by id desc;\nselect * from activities where id = 54747783; # 00UO400000pCzojMAC\n\nselect p.id, p.activity_type, pc.id, pc.name\nFROM playbooks p\njoin playbook_categories pc on p.id = pc.playbook_id\nwhere p.team_id = 1 and p.activity_type = 'event';\n\nSELECT * FROM crm_fields WHERE crm_configuration_id = 1 and object_type = 'event';\nSELECT * FROM crm_field_values WHERE crm_field_id = 4;\n\nselect * from crm_layouts cl join playbook_layouts pl on cl.id = pl.layout_id\nwhere crm_configuration_id = 1 and pl.playbook_id = 175;\n\nselect * from teams;\nSELECT r.* FROM automated_reports r\njoin teams t on r.team_id = t.id\nWHERE r.frequency = 'daily'\n and r.status = 1\nAND t.status = 'active'\nAND (r.expires_at >= now() OR r.expires_at IS NULL);\n\nselect * from automated_report_results where report_id IN (18, 33);\n\nselect * from activity_searches where id = 10932;\nselect * from activity_search_filters where activity_search_id = 10932;\nselect * from automated_reports order by id desc;\nselect * from automated_report_results where report_id IN (36);","depth":4,"value":"SELECT * FROM teams WHERE id = 1;\n\nselect * from crm_layouts where crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 283;\nSELECT * FROM crm_fields WHERE id = 2234;\nSELECT * FROM crm_field_values WHERE crm_field_id = 2234;\n\nselect * from crm_profiles where user_id = 143;\n\nselect * from record_types where crm_configuration_id = 39; # 0121K000001MHElQAO,0121K000001MHEqQAO\nselect * from business_processes where crm_configuration_id = 39;\n# 01941000000H669AAC, 01941000000H66JAAS\n\nselect * from record_type_field_values\n where record_type_id IN (24);\n\nselect * from crm_field_values where id IN (2730);\n\nselect * from crm_configurations where id = 39;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce'; #1035\n\n\nselect * from users where team_id = 1; # 222 group 3\nSELECT * FROM activities WHERE user_id = 222 order by id desc;\nselect * from sidekick_settings where team_id = 1;\nselect * from teams where id = 1;\nselect * from team_features where team_id = 1;\n\nselect * from activities where crm_configuration_id = 2\nand provider = 'ms-teams' and id = 608765;\n\nSELECT * FROM activities WHERE crm_configuration_id = 2 and crm_provider_id = '59523413338';\n\nselect * from sidekick_settings where team_id = 2;\n\nSELECT * FROM activities WHERE id = 608660;\nselect * from activity_summary_logs where activity_id = 608660;\nselect * from ai_prompts where transcription_id = 11214;\n\n# ********************************************************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('ed78a437-2804-450e-ab2f-56ab1c641346') = uuid;\n# id: 608818, crm: 59628809737\nSELECT * FROM activities WHERE uuid_to_bin('36b06e55-afdd-4782-8dee-c624cd0af191') = uuid;\n# id: 608821, crm: 59632069252\nSELECT ce.start_time, ce.end_time, a.id, a.uuid, crm_provider_id, calendar_event_id, title,\nplaybook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id,\nscheduled_start_time, scheduled_end_time, actual_start_time, actual_end_time, a.created_at\nFROM activities a\njoin calendar_events ce on a.calendar_event_id = ce.id\nWHERE a.id IN (608818, 608821);\n\nselect * from users where team_id = 1;\nselect * from team_settings where team_id = 1;\nselect * from crm_profiles where crm_configuration_id = 39 order by user_id;\n\nselect * from team_features where team_id = 1;\n\nselect * from users where team_id = 2;\n\nSELECT * FROM activities WHERE uuid_to_bin('ec7647e9-5225-458b-b475-f31aa2769204') = uuid; # 612639\n# Preslava N. Ivanova, grou id 3\n\nSELECT * FROM opportunities WHERE uuid_to_bin('a2928fe5-aec5-46cb-85d9-7654c89e46a6') = uuid;\n\nselect * from activities where opportunity_id = 344 and actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00';\n\nselect\n a.id,\n a.type,\n a.scheduled_start_time,\n a.actual_start_time,\n a.created_at,\n a.opportunity_id,\n a.status\nFROM activities a\nWHERE opportunity_id = 344\nand status IN ('completed', 'received', 'delivered')\nand (\n (a.actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')\nOR (a.created_at between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')\nOR (a.scheduled_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00'))\n;\n\nSELECT * FROM users WHERE id = 222;\n\nSELECT * FROM crm_profiles WHERE user_id = 222;\nselect * from crm_layouts where crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 281;\n\nselect * from group_deal_risk_types;\n\nselect * from opportunities where team_id = 1;\n\nSELECT * FROM opportunities WHERE id = 315;\nSELECT * FROM crm_field_data WHERE object_id = 315;\nselect * from crm_field_data where object_id = 260;\n\nselect * from generic_ai_prompts where subject_id = 315;\n\nselect * from teams; # 36, 21, 121, james.graham@bullhorn.jiminny.com\nSELECT * FROM social_accounts WHERE sociable_id = 121 and provider = 'bullhorn';\n\n# ************************************************************************************\nselect * from teams where id = 1;\nselect * from crm_configurations where id = 39;\nselect * from users where team_id = 1;\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 1;\n# 1 - 00541000004281rAAA\n# 204 - 0052g000003freeAAA\n# 429 - 0052g000003qGOiAAM\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce';\n\nselect * from activities where type = 'softphone'\nand created_at > '2024-12-11 15:24:36' order by id desc;\n\nselect * from activity_providers where team_id = 1;\nselect * from activity_provider_users where activity_provider_id = 328;\n\nselect * from opportunities where crm_configuration_id = 39\nAND account_id = 178 AND is_closed = false\norder by created_at DESC;\n\nselect * from contacts where id = 3952;\nselect * from accounts where id = 178;\n\n# ************************************************************************************\nselect * from teams where id = 36;\nselect * from crm_configurations where id = 21;\nselect * from users where team_id = 36;\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 36;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 36\nand sa.provider = 'bullhorn';\n\nselect * from social_accounts where id = 348;\nUPDATE social_accounts SET\nprovider_user_token = '21442_6802599_91:41179a58-21e7-4d7c-ad58-56bb666b2f65',\nprovider_refresh_token = '21442_6802599_91:01c6b335-3f2a-42e4-85ff-8a08fa65fceb',\nexpires = 1733998131,\nstate = 'connected'\nWHERE id = 348;\n\n# ************************************************************************************\nselect * from teams where id = 31;\nselect * from crm_configurations where id = 18;\n\nselect * from users where team_id = 31; # 257\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 31;\n\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 31\nand sa.provider = 'close';\n\nselect * from contacts where crm_configuration_id = 18;\n\n# ********************** NEPTUNE **************************************************************\nselect * from teams;\nselect * from users where id IN (1030, 1035, 1052);\nselect * from crm_configurations;\n\nselect * from users where team_id = 65; # 257\nselect * from team_settings where team_id = 65; # 257\nselect * from invitations where team_id = 65; # 257\nselect * from users where email = 'integration-account@jiminny.com'; # 257\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 65;\n\nselect * from crm_configurations where id = 53;\nselect * from accounts where crm_configuration_id = 53 order by id desc;\nselect * from leads where crm_configuration_id = 53 order by id desc;\nselect * from contacts where crm_configuration_id = 53 order by id desc;\nselect * from opportunities where crm_configuration_id = 53 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 53 order by id desc;\nselect * from crm_fields where crm_configuration_id = 53 order by id desc;\nselect * from crm_field_values where crm_field_id = 3341 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 53 order by id desc;\nselect * from stages where crm_configuration_id = 53 order by id desc;\n\n\nselect * from crm_profiles where crm_configuration_id = 13;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 65\nand sa.provider = 'integration-app';\n\nselect * from contacts where crm_configuration_id = 13;\n\nselect * from social_accounts where sociable_id = 283;\n\nSELECT * FROM opportunities WHERE crm_provider_id = '006O400000E9bzeIAB';\n\nselect * from activity_providers where team_id = 65;\nSELECT * FROM activities WHERE crm_configuration_id IN (51, 52, 53);\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 65\n;\n\n# ***************************** STAGING ********************************************\nSELECT * FROM teams;\nSELECT * FROM teams WHERE id = 88;\nSELECT * FROM teams WHERE id = 89;\nselect * from team_settings where team_id = 89;\nSELECT * FROM users WHERE team_id = 89;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 89;\n\nselect * from users;\nSELECT * FROM social_accounts WHERE sociable_id = 1761;\nSELECT * FROM crm_configurations WHERE id = 70;\nselect * from accounts where crm_configuration_id = 70 order by id desc;\nselect * from leads where crm_configuration_id = 70 order by id desc;\nselect * from contacts where crm_configuration_id = 70 order by id desc;\nselect * from opportunities where crm_configuration_id = 70 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 70 order by id desc;\nselect * from crm_fields where crm_configuration_id = 70 order by id desc;\nselect * from crm_field_values where crm_field_id = 3536 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 70 order by id desc;\nselect * from stages where crm_configuration_id = 70 order by id desc;\nselect * from business_processes where crm_configuration_id = 70 order by id desc;\nselect * from business_process_stages where business_process_id = 34;\n\nselect * from contacts where id = 10468;\n\nselect * from crm_layouts where crm_configuration_id = 70;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 388;\nSELECT * FROM crm_fields WHERE id IN (3533,3534,3535);\n\nselect * from activities where crm_configuration_id = 70\nand (account_id IS NOT NULL or lead_id IS NOT NULL or contact_id IS NOT NULL or opportunity_id IS NOT NULL) order by id desc;\n\nSELECT * FROM activities WHERE uuid_to_bin('2e10b60f-8a61-41c5-a3d4-28835353dc65') = uuid;\nSELECT * FROM activities where crm_configuration_id = 69 ;\n\nSELECT * FROM users WHERE email LIKE '%jiminny_web_sa2@jiminny.com%';\nSELECT * FROM activities WHERE uuid_to_bin('5a150c93-40fc-42ec-b3bd-c1d328e09f6e') = uuid;\nSELECT * FROM opportunities WHERE id = 385;\n\nselect * from participants p\njoin activities a on p.activity_id = a.id\nwhere a.crm_configuration_id = 70\nand (p.lead_id IS NOT NULL or p.contact_id IS NOT NULL);\nSELECT * FROM participants WHERE id = 1013638;\n\nselect * from teams where id = 90;\nselect * from users where team_id = 90;\nselect * from social_accounts where social_accounts.sociable_id IN (1960,1760);\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 71;\nselect * from invitations where team_id = 90;\n\nselect * from crm_configurations where id = 71;\nselect * from accounts where crm_configuration_id = 71 order by id desc;\nselect * from leads where crm_configuration_id = 71 order by id desc;\nselect * from contacts where crm_configuration_id = 71 order by id desc;\nselect * from opportunities where crm_configuration_id = 71 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 71 order by id desc;\nselect * from crm_fields where crm_configuration_id = 71 order by id desc;\nselect * from crm_field_values where crm_field_id = 3341 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 71 order by id desc;\nselect * from stages where crm_configuration_id = 71 order by id desc;\n\nselect * from users order by secondary_email desc;\nselect u.id, u.email, u.status, sa.id, sa.provider_user_id from social_accounts sa\n join users u on sa.sociable_id = u.id\nwhere sa.provider = 'google' and u.email LIKE 'aneliya%';\n\nselect * from failed_jobs order by id desc;\n\nselect * from users where email = 'ben.allwright@learningpeople.co.uk' or secondary_email = 'ben.allwright@learningpeople.co.uk';\n\nselect * from teams;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 39;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type = 'task';\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce';\n\n# ************************************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('c38b3895-fd0f-4b1f-9fb2-c170dba137c6') = uuid;\nSELECT * FROM crm_configurations WHERE id = 70;\n\nselect * from teams where id = 1;\nselect * from groups where team_id = 1;\nselect * from users where team_id = 1;\n\nselect o.id, o.name,o.close_date, u.id, u.name, u.group_id, r.id, r.display_name, g.name, g.scope from opportunities o\njoin users u on o.user_id = u.id\njoin groups g on u.group_id = g.id\njoin role_user ru on u.id = ru.user_id\njoin roles r on ru.role_id = r.id\nwhere o.crm_configuration_id = 39 and close_date > '2024-01-01 00:00:00';\n\nselect * from role_user where user_id = 143;\nselect * from roles;\n\nselect * from role_user;\nselect * from groups where id = 9;\nselect * from scope_groups where group_id = 9;\n\n# ************************************************************************************\nselect * from teams where id = 36;\nselect * from crm_configurations;\nSELECT * FROM social_accounts WHERE sociable_id = 121;\n\nhttps://crmsandbox.zoho.com/crm/jiminnyw4/tab/Leads/4776201000005049105\nhttps://crmsandbox.zoho.com/crm/\n\nhttps://crm.zoho.com/crm/org3469620/tab/Leads/230045000229559080\n https://crm.zoho.com/crm/\n org3469620\n\nSELECT * FROM activities WHERE uuid_to_bin('03382d20-c8bc-48e7-a3d4-90b52fa5ceab') = uuid;\n\nselect * from users where email LIKE \"%mobile_automation_%\";\nselect * from social_accounts where sociable_id IN (2228);\nselect * from crm_profiles where user_id IN (2222,2223,2226,2227);\n\nselect * from teams order by id desc;\nSELECT * FROM users WHERE id = 2229;\nSELECT * FROM crm_profiles WHERE user_id = 2229;\nselect * from opportunities where crm_configuration_id = 88;\nselect * from crm_fields where crm_configuration_id = 88;\nselect * from crm_profiles where crm_configuration_id = 88;\n\nSELECT * FROM teams WHERE id = 1;\n\nSELECT * FROM users WHERE id = 143;\nSELECT * FROM users WHERE uuid_to_bin('fde193d3-06a2-4e1a-8895-62b94039215d') = uuid;\nSELECT * FROM teams WHERE uuid_to_bin('73385071-a756-42ae-9c73-8b53f2309467') = uuid;\n\nhttps://app.staging.jiminny.com/ondemand?\n min_duration=1\n &\n only_recorded=1\n &\n user_id%5B%5D=641f1acb-16b8-42d1-8726-df52979dad0e\n &\n sequence_number=2\n\n select * from users where team_id = 1 and email like '%stoyan%'\n\nselect * from coaching_feedbacks;\n\nselect * from teams;\nSELECT * FROM users WHERE team_id = 36;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 19\nand sa.provider = 'pipedrive';\n\nselect * from users where id = 143;\n\nSELECT * FROM users WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\nSELECT * FROM teams WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\nSELECT * FROM activity_shares WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\n\nselect * from users where team_id = 2;\nselect * from activities where crm_configuration_id = 39\nand activities.scheduled_start_time BETWEEN '2025-04-09 00:00:00' AND '2025-04-09 23:59:59'\nAND user_id = 143\norder by id desc;\n\n# ************************************************************************************\nselect * from teams where id = 142; # 2312, 126\nselect * from team_settings;\nselect * from users where team_id = 142; # 21642\nSELECT * FROM social_accounts WHERE sociable_id = 21642;\nSELECT * FROM crm_profiles cp join users u ON u.id = cp.user_id WHERE team_id = 142;\nselect * from crm_profiles where id IN (93);\nselect * from invitations;\nselect * from team_features where team_id = 1;\n\nSELECT * FROM crm_configurations WHERE id = 126;\nselect * from accounts where crm_configuration_id = 126 order by id desc;\nselect * from leads where crm_configuration_id = 126 order by id desc;\nselect * from contacts where crm_configuration_id = 126 order by id desc;\nselect * from opportunities where crm_configuration_id = 126 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 126 order by id desc;\nselect * from crm_fields where crm_configuration_id = 126 # 11060\n# and type IN ('picklist', 'status')\n# and object_type = 'task'\norder by id desc;\n# 5731,5732,5733\nselect DISTINCT crm_field_id from crm_field_values where crm_field_id IN (11151,12239,12215,12185,12175,12165,12144,12137,12127,12109,12107,12105,12103,12092,12037,12005,12003,11987,11969,11958,11951,11942,11931,11924,11921,11917,11915,11901,11893,11883,11872,11870,11868,11866,11839,11833,11821,11793,11780,11777,11769,11757,11737,11735,11656,11645,11638,11629,11618,11611,11602,11591,11584,11581,11558,11544,11543,11534,11532,11529,11527,11503,11497,11493,11488,11470,11468,11457,11455,11397,11387,11372,11363,11348,11323,11318,11309,11301,11300,11292,11290,11286,11284,11256,11252,11242,11237,11233,11219,11176,11160) order by id desc;\nselect * from crm_layouts where crm_configuration_id = 126 order by id desc;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id in (300,299,298);\nselect * from stages where crm_configuration_id = 126 order by id desc;\nselect * from business_processes where crm_configuration_id = 126 order by id desc;\nselect * from business_process_stages where business_process_id IN (76,75,74,73);\nselect * from playbooks where team_id = 142;\nselect * from playbook_layouts where playbook_id IN (108);\nSELECT * FROM playbook_categories WHERE playbook_id IN (108);\n\nselect * from teams where id = 130;\nSELECT * FROM social_accounts WHERE sociable_id = 2291;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 2\nand sa.provider = 'hubspot';\n\nSELECT * FROM activities\n WHERE crm_configuration_id = 110;\n\nselect * from teams;\nselect * from crm_configurations;\n\nSELECT * FROM activities WHERE id = 628773;\nSELECT * FROM crm_profiles WHERE user_id = 1460;\nSELECT * FROM social_accounts WHERE sociable_id = 2291;\n\nselect * from teams;\nselect ru.*, pr.*, p.* from users u join role_user ru on ru.user_id = u.id\njoin permission_role pr on pr.role_id = ru.role_id\n join permissions p on p.id = pr.permission_id\nwhere team_id = 495 and p.name IN ('dial');\n\nselect * from teams where id = 145;\nselect * from crm_configurations where id = 129;\nselect * from social_accounts where sociable_id = 2317;\nSELECT * FROM activities WHERE uuid_to_bin('8dbab184-a333-4268-ad57-fb41f8d53a9a') = uuid;\n\nselect * from teams where id = 1;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 280;\nSELECT * FROM crm_layout_entities WHERE id = 5507;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type IN ('event');\n\nselect * from teams;\nselect * from activities where crm_configuration_id = 14;\n\nSELECT * FROM social_accounts where provider = 'copper';\n\nselect * from activities where id = 628467;\nselect * from participants where activity_id = 628467;\n\nSELECT * FROM contacts WHERE id = 3969;\nSELECT * FROM accounts WHERE id = 177;\n\nSELECT * FROM activities WHERE uuid_to_bin('4eb54c77-cfa3-2bd4-84a7-9ed46a21c988') = uuid;\n\n# ********************* BH\nselect * from teams where id = 36;\nSELECT * FROM crm_configurations WHERE id = 21;\nselect * from activities where crm_configuration_id = 21 and id = 607901;\nselect * from activities where crm_configuration_id = 21;\n\nselect * roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 226;\n\nselect * from migrations order by id desc;\n\n# mercury\n# neptune\n# earth\n\nselect * from teams;\nselect * from teams where id = 19;\nselect * from teams where id = 27;\nselect * from users where team_id = 27;\nSELECT * FROM crm_configurations WHERE id = 42;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 19\nand sa.provider = 'pipedrive';\n\nselect * from activities where id = 631461;\nSELECT * FROM crm_field_values WHERE crm_field_id = 180;\n\nselect * from teams where id = 2;\nSELECT * FROM social_accounts WHERE sociable_id = 89;\n\nSELECT * FROM activities WHERE uuid_to_bin('ba0c029a-bc14-4e17-8603-64174acebcbb') = uuid; # 634273\nselect * from activity_summary_logs where activity_id = 634273;\n\nselect * from sidekick_settings where team_id = 2;\n\nselect * from teams; # 2, 2\nSELECT * FROM crm_configurations WHERE team_id = 2; # 2\nselect * from team_features where team_id = 2;\nselect * from features;\nSELECT * FROM opportunities WHERE crm_configuration_id = 2 and crm_provider_id = '51317301383';\nSELECT * FROM opportunities WHERE crm_configuration_id = 2 order by id desc;\n\nselect * from automated_reports order by id desc;\nselect * from automated_report_results order by id desc;\nselect * from users where team_id = 1 and id IN (7160, 3248);\nselect * from migrations order by id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1;\nselect * from groups g JOIN playbooks p on g.playbook_id = p.id where g.team_id = 1;\nselect * from groups where id = 565;\nselect * from playbooks where team_id = 1;\nselect * from playbooks where id = 175;\nselect * from playbook_categories where playbook_id = 175;\nselect * from users where team_id = 1;\nselect * from users where id = 7160;\nselect * from crm_profiles where user_id = 7160;\nselect * from features;\nselect\n *\n# id, uuid, type, provider, playbook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id, stage_id,\n# crm_configuration_id, crm_provider_id, transcription_id, status\nfrom activities where crm_configuration_id = 1 and type = 'conference'\n# and crm_provider_id IS NOT NULL\nand provider != 'uploader' and actual_start_time IS NOT NULL\nORDER by id desc;\nselect * from activities where id = 54747783; # 00UO400000pCzojMAC\n\nselect p.id, p.activity_type, pc.id, pc.name\nFROM playbooks p\njoin playbook_categories pc on p.id = pc.playbook_id\nwhere p.team_id = 1 and p.activity_type = 'event';\n\nSELECT * FROM crm_fields WHERE crm_configuration_id = 1 and object_type = 'event';\nSELECT * FROM crm_field_values WHERE crm_field_id = 4;\n\nselect * from crm_layouts cl join playbook_layouts pl on cl.id = pl.layout_id\nwhere crm_configuration_id = 1 and pl.playbook_id = 175;\n\nselect * from teams;\nSELECT r.* FROM automated_reports r\njoin teams t on r.team_id = t.id\nWHERE r.frequency = 'daily'\n and r.status = 1\nAND t.status = 'active'\nAND (r.expires_at >= now() OR r.expires_at IS NULL);\n\nselect * from automated_report_results where report_id IN (18, 33);\n\nselect * from activity_searches where id = 10932;\nselect * from activity_search_filters where activity_search_id = 10932;\nselect * from automated_reports order by id desc;\nselect * from automated_report_results where report_id IN (36);","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.049609374,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.509375,"top":0.15208334,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.52070314,"top":0.15069444,"width":0.00859375,"height":0.015972223},"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.5292969,"top":0.15069444,"width":0.008203125,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Console\\Commands\\Reports;\n\nuse Carbon\\Carbon;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Contracts\\Bus\\Dispatcher as BusDispatcher;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Jobs\\AutomatedReports\\RequestGenerateAskJiminnyReportJob;\nuse Jiminny\\Jobs\\AutomatedReports\\RequestGenerateReportJob;\nuse Jiminny\\Models\\AutomatedReport;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Repositories\\AutomatedReportsRepository;\nuse Jiminny\\Services\\Kiosk\\AutomatedReports\\AutomatedReportsService;\nuse Psr\\Log\\LoggerInterface;\n\nclass AutomatedReportsCommand extends Command\n{\n /**\n * Log prefix for all log messages\n */\n private const string LOG_PREFIX = '[automated-reports]';\n\n /**\n * The name and signature of the console command.\n *\n * @var string\n */\n protected $signature = 'automated-reports \n {--report-id= : Process a specific report by ID or UUID (bypasses frequency scheduling)}';\n\n /**\n * The console command description.\n *\n * @var string\n */\n protected $description = 'Process automated reports based on their frequency (weekly, monthly, quarterly). \n Use --report-id to manually trigger a specific report by ID or UUID.';\n\n\n public function __construct(\n private readonly LoggerInterface $logger,\n private readonly BusDispatcher $dispatcher,\n private readonly AutomatedReportsRepository $reportRepository\n ) {\n parent::__construct();\n }\n\n /**\n * Execute the console command.\n *\n * @return int\n */\n public function handle(): int\n {\n $this->logger->info(self::LOG_PREFIX . ' Started');\n\n $now = Carbon::now();\n $isMonday = $now->isMonday();\n $isFirstDayOfMonth = $now->day === 1;\n $currentMonth = $now->month;\n\n // Check if the current month is a quarterly month (January, April, July, October)\n $isQuarterlyMonth = in_array($currentMonth, [1, 4, 7, 10], true);\n\n $this->logger->info(self::LOG_PREFIX . ' Checking conditions', [\n 'isMonday' => $isMonday,\n 'isFirstDayOfMonth' => $isFirstDayOfMonth,\n 'currentMonth' => $currentMonth,\n 'isQuarterlyMonth' => $isQuarterlyMonth,\n ]);\n\n // Process daily reports\n $this->processReports(AutomatedReportsService::FREQUENCY_DAILY);\n\n // Process weekly reports on Mondays\n if ($isMonday) {\n $this->processReports(AutomatedReportsService::FREQUENCY_WEEKLY);\n }\n\n // Process monthly reports on the first day of the month\n if ($isFirstDayOfMonth) {\n $this->processReports(AutomatedReportsService::FREQUENCY_MONTHLY);\n }\n\n // Process quarterly reports on the first day of January, April, July, and October\n if ($isFirstDayOfMonth && $isQuarterlyMonth) {\n $this->processReports(AutomatedReportsService::FREQUENCY_QUARTERLY);\n }\n\n $this->logger->info(self::LOG_PREFIX . ' Completed');\n\n return 0;\n }\n\n /**\n * Process reports for a specific frequency.\n *\n * @param string $frequency\n *\n * @return void\n */\n private function processReports(string $frequency): void\n {\n $this->logger->info(self::LOG_PREFIX . \" Processing $frequency reports\");\n\n $reportId = $this->option('report-id');\n if ($reportId !== null) {\n $reports = $this->getReportById($reportId);\n } else {\n // Get all enabled, not deleted reports with active teams for the specified frequency\n $reports = $this->reportRepository->getActiveReportsByFrequency($frequency);\n }\n\n $this->logger->info(self::LOG_PREFIX . \" Found {$reports->count()} $frequency reports to process\");\n\n /** @var AutomatedReport $report */\n foreach ($reports as $report) {\n $this->logger->info(self::LOG_PREFIX . ' Dispatching Generate Report job for report', [\n 'reportUuid' => $report->getUuid(),\n 'teamId' => $report->getTeamId(),\n 'frequency' => $report->getFrequency(),\n 'type' => $report->getType(),\n ]);\n\n $job = $report->isAskJiminnyReport()\n ? new RequestGenerateAskJiminnyReportJob($report->getUuid())\n : new RequestGenerateReportJob($report->getUuid());\n\n $this->dispatcher->dispatch($job);\n }\n }\n\n private function getReportById(string $reportId): Collection\n {\n $report = $this->reportRepository->findByIdOrUuid($reportId);\n\n if ($report === null) {\n $this->logger->warning(self::LOG_PREFIX . ' Report not found for --report-id', ['reportId' => $reportId]);\n $this->warn(\"Report not found: {$reportId}\");\n\n return collect();\n }\n\n if (! $report->getStatus()) {\n $this->logger->warning(self::LOG_PREFIX . ' Report is inactive, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n ]);\n $this->warn('Report is inactive — processing anyway (manual override).');\n }\n\n $team = $report->getTeam();\n if ($team->getStatus() !== Team::STATUS_ACTIVE) {\n $this->logger->warning(self::LOG_PREFIX . ' Team is not active, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n 'teamId' => $report->getTeamId(),\n 'teamStatus' => $team->getStatus(),\n ]);\n $this->warn(\"Team #{$report->getTeamId()} is not active — processing anyway (manual override).\");\n }\n\n if ($report->isExpired()) {\n $this->logger->warning(self::LOG_PREFIX . ' Report is expired, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n 'expiresAt' => $report->getExpiresAt()?->toDateString(),\n ]);\n $this->warn('Report is expired (expires_at: ' . $report->getExpiresAt()?->toDateString() . ') — processing anyway (manual override).');\n }\n\n $this->info(self::LOG_PREFIX . ' Automated report found ' . $report->getCustomName());\n\n return collect([$report]);\n }\n}","depth":4,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Console\\Commands\\Reports;\n\nuse Carbon\\Carbon;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Contracts\\Bus\\Dispatcher as BusDispatcher;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Jobs\\AutomatedReports\\RequestGenerateAskJiminnyReportJob;\nuse Jiminny\\Jobs\\AutomatedReports\\RequestGenerateReportJob;\nuse Jiminny\\Models\\AutomatedReport;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Repositories\\AutomatedReportsRepository;\nuse Jiminny\\Services\\Kiosk\\AutomatedReports\\AutomatedReportsService;\nuse Psr\\Log\\LoggerInterface;\n\nclass AutomatedReportsCommand extends Command\n{\n /**\n * Log prefix for all log messages\n */\n private const string LOG_PREFIX = '[automated-reports]';\n\n /**\n * The name and signature of the console command.\n *\n * @var string\n */\n protected $signature = 'automated-reports \n {--report-id= : Process a specific report by ID or UUID (bypasses frequency scheduling)}';\n\n /**\n * The console command description.\n *\n * @var string\n */\n protected $description = 'Process automated reports based on their frequency (weekly, monthly, quarterly). \n Use --report-id to manually trigger a specific report by ID or UUID.';\n\n\n public function __construct(\n private readonly LoggerInterface $logger,\n private readonly BusDispatcher $dispatcher,\n private readonly AutomatedReportsRepository $reportRepository\n ) {\n parent::__construct();\n }\n\n /**\n * Execute the console command.\n *\n * @return int\n */\n public function handle(): int\n {\n $this->logger->info(self::LOG_PREFIX . ' Started');\n\n $now = Carbon::now();\n $isMonday = $now->isMonday();\n $isFirstDayOfMonth = $now->day === 1;\n $currentMonth = $now->month;\n\n // Check if the current month is a quarterly month (January, April, July, October)\n $isQuarterlyMonth = in_array($currentMonth, [1, 4, 7, 10], true);\n\n $this->logger->info(self::LOG_PREFIX . ' Checking conditions', [\n 'isMonday' => $isMonday,\n 'isFirstDayOfMonth' => $isFirstDayOfMonth,\n 'currentMonth' => $currentMonth,\n 'isQuarterlyMonth' => $isQuarterlyMonth,\n ]);\n\n // Process daily reports\n $this->processReports(AutomatedReportsService::FREQUENCY_DAILY);\n\n // Process weekly reports on Mondays\n if ($isMonday) {\n $this->processReports(AutomatedReportsService::FREQUENCY_WEEKLY);\n }\n\n // Process monthly reports on the first day of the month\n if ($isFirstDayOfMonth) {\n $this->processReports(AutomatedReportsService::FREQUENCY_MONTHLY);\n }\n\n // Process quarterly reports on the first day of January, April, July, and October\n if ($isFirstDayOfMonth && $isQuarterlyMonth) {\n $this->processReports(AutomatedReportsService::FREQUENCY_QUARTERLY);\n }\n\n $this->logger->info(self::LOG_PREFIX . ' Completed');\n\n return 0;\n }\n\n /**\n * Process reports for a specific frequency.\n *\n * @param string $frequency\n *\n * @return void\n */\n private function processReports(string $frequency): void\n {\n $this->logger->info(self::LOG_PREFIX . \" Processing $frequency reports\");\n\n $reportId = $this->option('report-id');\n if ($reportId !== null) {\n $reports = $this->getReportById($reportId);\n } else {\n // Get all enabled, not deleted reports with active teams for the specified frequency\n $reports = $this->reportRepository->getActiveReportsByFrequency($frequency);\n }\n\n $this->logger->info(self::LOG_PREFIX . \" Found {$reports->count()} $frequency reports to process\");\n\n /** @var AutomatedReport $report */\n foreach ($reports as $report) {\n $this->logger->info(self::LOG_PREFIX . ' Dispatching Generate Report job for report', [\n 'reportUuid' => $report->getUuid(),\n 'teamId' => $report->getTeamId(),\n 'frequency' => $report->getFrequency(),\n 'type' => $report->getType(),\n ]);\n\n $job = $report->isAskJiminnyReport()\n ? new RequestGenerateAskJiminnyReportJob($report->getUuid())\n : new RequestGenerateReportJob($report->getUuid());\n\n $this->dispatcher->dispatch($job);\n }\n }\n\n private function getReportById(string $reportId): Collection\n {\n $report = $this->reportRepository->findByIdOrUuid($reportId);\n\n if ($report === null) {\n $this->logger->warning(self::LOG_PREFIX . ' Report not found for --report-id', ['reportId' => $reportId]);\n $this->warn(\"Report not found: {$reportId}\");\n\n return collect();\n }\n\n if (! $report->getStatus()) {\n $this->logger->warning(self::LOG_PREFIX . ' Report is inactive, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n ]);\n $this->warn('Report is inactive — processing anyway (manual override).');\n }\n\n $team = $report->getTeam();\n if ($team->getStatus() !== Team::STATUS_ACTIVE) {\n $this->logger->warning(self::LOG_PREFIX . ' Team is not active, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n 'teamId' => $report->getTeamId(),\n 'teamStatus' => $team->getStatus(),\n ]);\n $this->warn(\"Team #{$report->getTeamId()} is not active — processing anyway (manual override).\");\n }\n\n if ($report->isExpired()) {\n $this->logger->warning(self::LOG_PREFIX . ' Report is expired, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n 'expiresAt' => $report->getExpiresAt()?->toDateString(),\n ]);\n $this->warn('Report is expired (expires_at: ' . $report->getExpiresAt()?->toDateString() . ') — processing anyway (manual override).');\n }\n\n $this->info(self::LOG_PREFIX . ' Automated report found ' . $report->getCustomName());\n\n return collect([$report]);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.0140625,"top":0.041666668,"width":0.028515626,"height":0.021527778},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9074135532212230457
|
6686649039917036621
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
16
14
13
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE id = 1;
select * from crm_layouts where crm_configuration_id = 39;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 283;
SELECT * FROM crm_fields WHERE id = 2234;
SELECT * FROM crm_field_values WHERE crm_field_id = 2234;
select * from crm_profiles where user_id = 143;
select * from record_types where crm_configuration_id = 39; # 0121K000001MHElQAO,0121K000001MHEqQAO
select * from business_processes where crm_configuration_id = 39;
# 01941000000H669AAC, 01941000000H66JAAS
select * from record_type_field_values
where record_type_id IN (24);
select * from crm_field_values where id IN (2730);
select * from crm_configurations where id = 39;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce'; #1035
select * from users where team_id = 1; # 222 group 3
SELECT * FROM activities WHERE user_id = 222 order by id desc;
select * from sidekick_settings where team_id = 1;
select * from teams where id = 1;
select * from team_features where team_id = 1;
select * from activities where crm_configuration_id = 2
and provider = 'ms-teams' and id = 608765;
SELECT * FROM activities WHERE crm_configuration_id = 2 and crm_provider_id = '59523413338';
select * from sidekick_settings where team_id = 2;
SELECT * FROM activities WHERE id = 608660;
select * from activity_summary_logs where activity_id = 608660;
select * from ai_prompts where transcription_id = 11214;
# [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('ed78a437-2804-450e-ab2f-56ab1c641346') = uuid;
# id: 608818, crm: 59628809737
SELECT * FROM activities WHERE uuid_to_bin('36b06e55-afdd-4782-8dee-c624cd0af191') = uuid;
# id: 608821, crm: 59632069252
SELECT ce.start_time, ce.end_time, a.id, a.uuid, crm_provider_id, calendar_event_id, title,
playbook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id,
scheduled_start_time, scheduled_end_time, actual_start_time, actual_end_time, a.created_at
FROM activities a
join calendar_events ce on a.calendar_event_id = ce.id
WHERE a.id IN (608818, 608821);
select * from users where team_id = 1;
select * from team_settings where team_id = 1;
select * from crm_profiles where crm_configuration_id = 39 order by user_id;
select * from team_features where team_id = 1;
select * from users where team_id = 2;
SELECT * FROM activities WHERE uuid_to_bin('ec7647e9-5225-458b-b475-f31aa2769204') = uuid; # 612639
# Preslava N. Ivanova, grou id 3
SELECT * FROM opportunities WHERE uuid_to_bin('a2928fe5-aec5-46cb-85d9-7654c89e46a6') = uuid;
select * from activities where opportunity_id = 344 and actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00';
select
a.id,
a.type,
a.scheduled_start_time,
a.actual_start_time,
a.created_at,
a.opportunity_id,
a.status
FROM activities a
WHERE opportunity_id = 344
and status IN ('completed', 'received', 'delivered')
and (
(a.actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')
OR (a.created_at between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')
OR (a.scheduled_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00'))
;
SELECT * FROM users WHERE id = 222;
SELECT * FROM crm_profiles WHERE user_id = 222;
select * from crm_layouts where crm_configuration_id = 39;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 281;
select * from group_deal_risk_types;
select * from opportunities where team_id = 1;
SELECT * FROM opportunities WHERE id = 315;
SELECT * FROM crm_field_data WHERE object_id = 315;
select * from crm_field_data where object_id = 260;
select * from generic_ai_prompts where subject_id = 315;
select * from teams; # 36, 21, 121, [EMAIL]
SELECT * FROM social_accounts WHERE sociable_id = 121 and provider = 'bullhorn';
# [PASSWORD_DOTS]
select * from teams where id = 1;
select * from crm_configurations where id = 39;
select * from users where team_id = 1;
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 1;
# 1 - 00541000004281rAAA
# 204 - 0052g000003freeAAA
# 429 - 0052g000003qGOiAAM
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce';
select * from activities where type = 'softphone'
and created_at > '2024-12-11 15:24:36' order by id desc;
select * from activity_providers where team_id = 1;
select * from activity_provider_users where activity_provider_id = 328;
select * from opportunities where crm_configuration_id = 39
AND account_id = 178 AND is_closed = false
order by created_at DESC;
select * from contacts where id = 3952;
select * from accounts where id = 178;
# [PASSWORD_DOTS]
select * from teams where id = 36;
select * from crm_configurations where id = 21;
select * from users where team_id = 36;
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 36;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 36
and sa.provider = 'bullhorn';
select * from social_accounts where id = 348;
UPDATE social_accounts SET
provider_user_token = '21442_6802599_91:41179a58-21e7-4d7c-ad58-56bb666b2f65',
provider_refresh_token = '21442_6802599_91:01c6b335-3f2a-42e4-85ff-8a08fa65fceb',
expires = 1733998131,
state = 'connected'
WHERE id = 348;
# [PASSWORD_DOTS]
select * from teams where id = 31;
select * from crm_configurations where id = 18;
select * from users where team_id = 31; # 257
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 31;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 31
and sa.provider = 'close';
select * from contacts where crm_configuration_id = 18;
# [PASSWORD_DOTS] NEPTUNE [PASSWORD_DOTS]
select * from teams;
select * from users where id IN (1030, 1035, 1052);
select * from crm_configurations;
select * from users where team_id = 65; # 257
select * from team_settings where team_id = 65; # 257
select * from invitations where team_id = 65; # 257
select * from users where email = '[EMAIL]'; # 257
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 65;
select * from crm_configurations where id = 53;
select * from accounts where crm_configuration_id = 53 order by id desc;
select * from leads where crm_configuration_id = 53 order by id desc;
select * from contacts where crm_configuration_id = 53 order by id desc;
select * from opportunities where crm_configuration_id = 53 order by id desc;
select * from crm_profiles where crm_configuration_id = 53 order by id desc;
select * from crm_fields where crm_configuration_id = 53 order by id desc;
select * from crm_field_values where crm_field_id = 3341 order by id desc;
select * from crm_layouts where crm_configuration_id = 53 order by id desc;
select * from stages where crm_configuration_id = 53 order by id desc;
select * from crm_profiles where crm_configuration_id = 13;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 65
and sa.provider = 'integration-app';
select * from contacts where crm_configuration_id = 13;
select * from social_accounts where sociable_id = 283;
SELECT * FROM opportunities WHERE crm_provider_id = '006O400000E9bzeIAB';
select * from activity_providers where team_id = 65;
SELECT * FROM activities WHERE crm_configuration_id IN (51, 52, 53);
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 65
;
# [PASSWORD_DOTS] STAGING [PASSWORD_DOTS]
SELECT * FROM teams;
SELECT * FROM teams WHERE id = 88;
SELECT * FROM teams WHERE id = 89;
select * from team_settings where team_id = 89;
SELECT * FROM users WHERE team_id = 89;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 89;
select * from users;
SELECT * FROM social_accounts WHERE sociable_id = 1761;
SELECT * FROM crm_configurations WHERE id = 70;
select * from accounts where crm_configuration_id = 70 order by id desc;
select * from leads where crm_configuration_id = 70 order by id desc;
select * from contacts where crm_configuration_id = 70 order by id desc;
select * from opportunities where crm_configuration_id = 70 order by id desc;
select * from crm_profiles where crm_configuration_id = 70 order by id desc;
select * from crm_fields where crm_configuration_id = 70 order by id desc;
select * from crm_field_values where crm_field_id = 3536 order by id desc;
select * from crm_layouts where crm_configuration_id = 70 order by id desc;
select * from stages where crm_configuration_id = 70 order by id desc;
select * from business_processes where crm_configuration_id = 70 order by id desc;
select * from business_process_stages where business_process_id = 34;
select * from contacts where id = 10468;
select * from crm_layouts where crm_configuration_id = 70;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 388;
SELECT * FROM crm_fields WHERE id IN (3533,3534,3535);
select * from activities where crm_configuration_id = 70
and (account_id IS NOT NULL or lead_id IS NOT NULL or contact_id IS NOT NULL or opportunity_id IS NOT NULL) order by id desc;
SELECT * FROM activities WHERE uuid_to_bin('2e10b60f-8a61-41c5-a3d4-28835353dc65') = uuid;
SELECT * FROM activities where crm_configuration_id = 69 ;
SELECT * FROM users WHERE email LIKE '%[EMAIL]%';
SELECT * FROM activities WHERE uuid_to_bin('5a150c93-40fc-42ec-b3bd-c1d328e09f6e') = uuid;
SELECT * FROM opportunities WHERE id = 385;
select * from participants p
join activities a on p.activity_id = a.id
where a.crm_configuration_id = 70
and (p.lead_id IS NOT NULL or p.contact_id IS NOT NULL);
SELECT * FROM participants WHERE id = 1013638;
select * from teams where id = 90;
select * from users where team_id = 90;
select * from social_accounts where social_accounts.sociable_id IN (1960,1760);
SELECT * FROM crm_profiles WHERE crm_configuration_id = 71;
select * from invitations where team_id = 90;
select * from crm_configurations where id = 71;
select * from accounts where crm_configuration_id = 71 order by id desc;
select * from leads where crm_configuration_id = 71 order by id desc;
select * from contacts where crm_configuration_id = 71 order by id desc;
select * from opportunities where crm_configuration_id = 71 order by id desc;
select * from crm_profiles where crm_configuration_id = 71 order by id desc;
select * from crm_fields where crm_configuration_id = 71 order by id desc;
select * from crm_field_values where crm_field_id = 3341 order by id desc;
select * from crm_layouts where crm_configuration_id = 71 order by id desc;
select * from stages where crm_configuration_id = 71 order by id desc;
select * from users order by secondary_email desc;
select u.id, u.email, u.status, sa.id, sa.provider_user_id from social_accounts sa
join users u on sa.sociable_id = u.id
where sa.provider = 'google' and u.email LIKE 'aneliya%';
select * from failed_jobs order by id desc;
select * from users where email = '[EMAIL]' or secondary_email = '[EMAIL]';
select * from teams;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 39;
SELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type = 'task';
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce';
# [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('c38b3895-fd0f-4b1f-9fb2-c170dba137c6') = uuid;
SELECT * FROM crm_configurations WHERE id = 70;
select * from teams where id = 1;
select * from groups where team_id = 1;
select * from users where team_id = 1;
select o.id, o.name,o.close_date, u.id, u.name, u.group_id, r.id, r.display_name, g.name, g.scope from opportunities o
join users u on o.user_id = u.id
join groups g on u.group_id = g.id
join role_user ru on u.id = ru.user_id
join roles r on ru.role_id = r.id
where o.crm_configuration_id = 39 and close_date > '2024-01-01 00:00:00';
select * from role_user where user_id = 143;
select * from roles;
select * from role_user;
select * from groups where id = 9;
select * from scope_groups where group_id = 9;
# [PASSWORD_DOTS]
select * from teams where id = 36;
select * from crm_configurations;
SELECT * FROM social_accounts WHERE sociable_id = 121;
[URL_WITH_CREDENTIALS] string
*/
protected $signature = 'automated-reports
{--report-id= : Process a specific report by ID or UUID (bypasses frequency scheduling)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process automated reports based on their frequency (weekly, monthly, quarterly).
Use --report-id to manually trigger a specific report by ID or UUID.';
public function __construct(
private readonly LoggerInterface $logger,
private readonly BusDispatcher $dispatcher,
private readonly AutomatedReportsRepository $reportRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->logger->info(self::LOG_PREFIX . ' Started');
$now = Carbon::now();
$isMonday = $now->isMonday();
$isFirstDayOfMonth = $now->day === 1;
$currentMonth = $now->month;
// Check if the current month is a quarterly month (January, April, July, October)
$isQuarterlyMonth = in_array($currentMonth, [1, 4, 7, 10], true);
$this->logger->info(self::LOG_PREFIX . ' Checking conditions', [
'isMonday' => $isMonday,
'isFirstDayOfMonth' => $isFirstDayOfMonth,
'currentMonth' => $currentMonth,
'isQuarterlyMonth' => $isQuarterlyMonth,
]);
// Process daily reports
$this->processReports(AutomatedReportsService::FREQUENCY_DAILY);
// Process weekly reports on Mondays
if ($isMonday) {
$this->processReports(AutomatedReportsService::FREQUENCY_WEEKLY);
}
// Process monthly reports on the first day of the month
if ($isFirstDayOfMonth) {
$this->processReports(AutomatedReportsService::FREQUENCY_MONTHLY);
}
// Process quarterly reports on the first day of January, April, July, and October
if ($isFirstDayOfMonth && $isQuarterlyMonth) {
$this->processReports(AutomatedReportsService::FREQUENCY_QUARTERLY);
}
$this->logger->info(self::LOG_PREFIX . ' Completed');
return 0;
}
/**
* Process reports for a specific frequency.
*
* @param string $frequency
*
* @return void
*/
private function processReports(string $frequency): void
{
$this->logger->info(self::LOG_PREFIX . " Processing $frequency reports");
$reportId = $this->option('report-id');
if ($reportId !== null) {
$reports = $this->getReportById($reportId);
} else {
// Get all enabled, not deleted reports with active teams for the specified frequency
$reports = $this->reportRepository->getActiveReportsByFrequency($frequency);
}
$this->logger->info(self::LOG_PREFIX . " Found {$reports->count()} $frequency reports to process");
/** @var AutomatedReport $report */
foreach ($reports as $report) {
$this->logger->info(self::LOG_PREFIX . ' Dispatching Generate Report job for report', [
'reportUuid' => $report->getUuid(),
'teamId' => $report->getTeamId(),
'frequency' => $report->getFrequency(),
'type' => $report->getType(),
]);
$job = $report->isAskJiminnyReport()
? new RequestGenerateAskJiminnyReportJob($report->getUuid())
: new RequestGenerateReportJob($report->getUuid());
$this->dispatcher->dispatch($job);
}
}
private function getReportById(string $reportId): Collection
{
$report = $this->reportRepository->findByIdOrUuid($reportId);
if ($report === null) {
$this->logger->warning(self::LOG_PREFIX . ' Report not found for --report-id', ['reportId' => $reportId]);
$this->warn("Report not found: {$reportId}");
return collect();
}
if (! $report->getStatus()) {
$this->logger->warning(self::LOG_PREFIX . ' Report is inactive, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
]);
$this->warn('Report is inactive — processing anyway (manual override).');
}
$team = $report->getTeam();
if ($team->getStatus() !== Team::STATUS_ACTIVE) {
$this->logger->warning(self::LOG_PREFIX . ' Team is not active, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
'teamId' => $report->getTeamId(),
'teamStatus' => $team->getStatus(),
]);
$this->warn("Team #{$report->getTeamId()} is not active — processing anyway (manual override).");
}
if ($report->isExpired()) {
$this->logger->warning(self::LOG_PREFIX . ' Report is expired, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
'expiresAt' => $report->getExpiresAt()?->toDateString(),
]);
$this->warn('Report is expired (expires_at: ' . $report->getExpiresAt()?->toDateString() . ') — processing anyway (manual override).');
}
$this->info(self::LOG_PREFIX . ' Automated report found ' . $report->getCustomName());
return collect([$report]);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
12516
|
|
12536
|
269
|
43
|
2026-04-14T11:23:52.311259+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-14/1776 /Users/lukas/.screenpipe/data/data/2026-04-14/1776165832311_m2.jpg...
|
PhpStorm
|
faVsco.js – console [STAGING]
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
16
14
13
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE id = 1;
select * from crm_layouts where crm_configuration_id = 39;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 283;
SELECT * FROM crm_fields WHERE id = 2234;
SELECT * FROM crm_field_values WHERE crm_field_id = 2234;
select * from crm_profiles where user_id = 143;
select * from record_types where crm_configuration_id = 39; # 0121K000001MHElQAO,0121K000001MHEqQAO
select * from business_processes where crm_configuration_id = 39;
# 01941000000H669AAC, 01941000000H66JAAS
select * from record_type_field_values
where record_type_id IN (24);
select * from crm_field_values where id IN (2730);
select * from crm_configurations where id = 39;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce'; #1035
select * from users where team_id = 1; # 222 group 3
SELECT * FROM activities WHERE user_id = 222 order by id desc;
select * from sidekick_settings where team_id = 1;
select * from teams where id = 1;
select * from team_features where team_id = 1;
select * from activities where crm_configuration_id = 2
and provider = 'ms-teams' and id = 608765;
SELECT * FROM activities WHERE crm_configuration_id = 2 and crm_provider_id = '59523413338';
select * from sidekick_settings where team_id = 2;
SELECT * FROM activities WHERE id = 608660;
select * from activity_summary_logs where activity_id = 608660;
select * from ai_prompts where transcription_id = 11214;
# [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('ed78a437-2804-450e-ab2f-56ab1c641346') = uuid;
# id: 608818, crm: 59628809737
SELECT * FROM activities WHERE uuid_to_bin('36b06e55-afdd-4782-8dee-c624cd0af191') = uuid;
# id: 608821, crm: 59632069252
SELECT ce.start_time, ce.end_time, a.id, a.uuid, crm_provider_id, calendar_event_id, title,
playbook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id,
scheduled_start_time, scheduled_end_time, actual_start_time, actual_end_time, a.created_at
FROM activities a
join calendar_events ce on a.calendar_event_id = ce.id
WHERE a.id IN (608818, 608821);
select * from users where team_id = 1;
select * from team_settings where team_id = 1;
select * from crm_profiles where crm_configuration_id = 39 order by user_id;
select * from team_features where team_id = 1;
select * from users where team_id = 2;
SELECT * FROM activities WHERE uuid_to_bin('ec7647e9-5225-458b-b475-f31aa2769204') = uuid; # 612639
# Preslava N. Ivanova, grou id 3
SELECT * FROM opportunities WHERE uuid_to_bin('a2928fe5-aec5-46cb-85d9-7654c89e46a6') = uuid;
select * from activities where opportunity_id = 344 and actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00';
select
a.id,
a.type,
a.scheduled_start_time,
a.actual_start_time,
a.created_at,
a.opportunity_id,
a.status
FROM activities a
WHERE opportunity_id = 344
and status IN ('completed', 'received', 'delivered')
and (
(a.actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')
OR (a.created_at between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')
OR (a.scheduled_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00'))
;
SELECT * FROM users WHERE id = 222;
SELECT * FROM crm_profiles WHERE user_id = 222;
select * from crm_layouts where crm_configuration_id = 39;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 281;
select * from group_deal_risk_types;
select * from opportunities where team_id = 1;
SELECT * FROM opportunities WHERE id = 315;
SELECT * FROM crm_field_data WHERE object_id = 315;
select * from crm_field_data where object_id = 260;
select * from generic_ai_prompts where subject_id = 315;
select * from teams; # 36, 21, 121, [EMAIL]
SELECT * FROM social_accounts WHERE sociable_id = 121 and provider = 'bullhorn';
# [PASSWORD_DOTS]
select * from teams where id = 1;
select * from crm_configurations where id = 39;
select * from users where team_id = 1;
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 1;
# 1 - 00541000004281rAAA
# 204 - 0052g000003freeAAA
# 429 - 0052g000003qGOiAAM
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce';
select * from activities where type = 'softphone'
and created_at > '2024-12-11 15:24:36' order by id desc;
select * from activity_providers where team_id = 1;
select * from activity_provider_users where activity_provider_id = 328;
select * from opportunities where crm_configuration_id = 39
AND account_id = 178 AND is_closed = false
order by created_at DESC;
select * from contacts where id = 3952;
select * from accounts where id = 178;
# [PASSWORD_DOTS]
select * from teams where id = 36;
select * from crm_configurations where id = 21;
select * from users where team_id = 36;
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 36;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 36
and sa.provider = 'bullhorn';
select * from social_accounts where id = 348;
UPDATE social_accounts SET
provider_user_token = '21442_6802599_91:41179a58-21e7-4d7c-ad58-56bb666b2f65',
provider_refresh_token = '21442_6802599_91:01c6b335-3f2a-42e4-85ff-8a08fa65fceb',
expires = 1733998131,
state = 'connected'
WHERE id = 348;
# [PASSWORD_DOTS]
select * from teams where id = 31;
select * from crm_configurations where id = 18;
select * from users where team_id = 31; # 257
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 31;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 31
and sa.provider = 'close';
select * from contacts where crm_configuration_id = 18;
# [PASSWORD_DOTS] NEPTUNE [PASSWORD_DOTS]
select * from teams;
select * from users where id IN (1030, 1035, 1052);
select * from crm_configurations;
select * from users where team_id = 65; # 257
select * from team_settings where team_id = 65; # 257
select * from invitations where team_id = 65; # 257
select * from users where email = '[EMAIL]'; # 257
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 65;
select * from crm_configurations where id = 53;
select * from accounts where crm_configuration_id = 53 order by id desc;
select * from leads where crm_configuration_id = 53 order by id desc;
select * from contacts where crm_configuration_id = 53 order by id desc;
select * from opportunities where crm_configuration_id = 53 order by id desc;
select * from crm_profiles where crm_configuration_id = 53 order by id desc;
select * from crm_fields where crm_configuration_id = 53 order by id desc;
select * from crm_field_values where crm_field_id = 3341 order by id desc;
select * from crm_layouts where crm_configuration_id = 53 order by id desc;
select * from stages where crm_configuration_id = 53 order by id desc;
select * from crm_profiles where crm_configuration_id = 13;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 65
and sa.provider = 'integration-app';
select * from contacts where crm_configuration_id = 13;
select * from social_accounts where sociable_id = 283;
SELECT * FROM opportunities WHERE crm_provider_id = '006O400000E9bzeIAB';
select * from activity_providers where team_id = 65;
SELECT * FROM activities WHERE crm_configuration_id IN (51, 52, 53);
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 65
;
# [PASSWORD_DOTS] STAGING [PASSWORD_DOTS]
SELECT * FROM teams;
SELECT * FROM teams WHERE id = 88;
SELECT * FROM teams WHERE id = 89;
select * from team_settings where team_id = 89;
SELECT * FROM users WHERE team_id = 89;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 89;
select * from users;
SELECT * FROM social_accounts WHERE sociable_id = 1761;
SELECT * FROM crm_configurations WHERE id = 70;
select * from accounts where crm_configuration_id = 70 order by id desc;
select * from leads where crm_configuration_id = 70 order by id desc;
select * from contacts where crm_configuration_id = 70 order by id desc;
select * from opportunities where crm_configuration_id = 70 order by id desc;
select * from crm_profiles where crm_configuration_id = 70 order by id desc;
select * from crm_fields where crm_configuration_id = 70 order by id desc;
select * from crm_field_values where crm_field_id = 3536 order by id desc;
select * from crm_layouts where crm_configuration_id = 70 order by id desc;
select * from stages where crm_configuration_id = 70 order by id desc;
select * from business_processes where crm_configuration_id = 70 order by id desc;
select * from business_process_stages where business_process_id = 34;
select * from contacts where id = 10468;
select * from crm_layouts where crm_configuration_id = 70;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 388;
SELECT * FROM crm_fields WHERE id IN (3533,3534,3535);
select * from activities where crm_configuration_id = 70
and (account_id IS NOT NULL or lead_id IS NOT NULL or contact_id IS NOT NULL or opportunity_id IS NOT NULL) order by id desc;
SELECT * FROM activities WHERE uuid_to_bin('2e10b60f-8a61-41c5-a3d4-28835353dc65') = uuid;
SELECT * FROM activities where crm_configuration_id = 69 ;
SELECT * FROM users WHERE email LIKE '%[EMAIL]%';
SELECT * FROM activities WHERE uuid_to_bin('5a150c93-40fc-42ec-b3bd-c1d328e09f6e') = uuid;
SELECT * FROM opportunities WHERE id = 385;
select * from participants p
join activities a on p.activity_id = a.id
where a.crm_configuration_id = 70
and (p.lead_id IS NOT NULL or p.contact_id IS NOT NULL);
SELECT * FROM participants WHERE id = 1013638;
select * from teams where id = 90;
select * from users where team_id = 90;
select * from social_accounts where social_accounts.sociable_id IN (1960,1760);
SELECT * FROM crm_profiles WHERE crm_configuration_id = 71;
select * from invitations where team_id = 90;
select * from crm_configurations where id = 71;
select * from accounts where crm_configuration_id = 71 order by id desc;
select * from leads where crm_configuration_id = 71 order by id desc;
select * from contacts where crm_configuration_id = 71 order by id desc;
select * from opportunities where crm_configuration_id = 71 order by id desc;
select * from crm_profiles where crm_configuration_id = 71 order by id desc;
select * from crm_fields where crm_configuration_id = 71 order by id desc;
select * from crm_field_values where crm_field_id = 3341 order by id desc;
select * from crm_layouts where crm_configuration_id = 71 order by id desc;
select * from stages where crm_configuration_id = 71 order by id desc;
select * from users order by secondary_email desc;
select u.id, u.email, u.status, sa.id, sa.provider_user_id from social_accounts sa
join users u on sa.sociable_id = u.id
where sa.provider = 'google' and u.email LIKE 'aneliya%';
select * from failed_jobs order by id desc;
select * from users where email = '[EMAIL]' or secondary_email = '[EMAIL]';
select * from teams;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 39;
SELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type = 'task';
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce';
# [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('c38b3895-fd0f-4b1f-9fb2-c170dba137c6') = uuid;
SELECT * FROM crm_configurations WHERE id = 70;
select * from teams where id = 1;
select * from groups where team_id = 1;
select * from users where team_id = 1;
select o.id, o.name,o.close_date, u.id, u.name, u.group_id, r.id, r.display_name, g.name, g.scope from opportunities o
join users u on o.user_id = u.id
join groups g on u.group_id = g.id
join role_user ru on u.id = ru.user_id
join roles r on ru.role_id = r.id
where o.crm_configuration_id = 39 and close_date > '2024-01-01 00:00:00';
select * from role_user where user_id = 143;
select * from roles;
select * from role_user;
select * from groups where id = 9;
select * from scope_groups where group_id = 9;
# [PASSWORD_DOTS]
select * from teams where id = 36;
select * from crm_configurations;
SELECT * FROM social_accounts WHERE sociable_id = 121;
[URL_WITH_CREDENTIALS] string
*/
protected $signature = 'automated-reports
{--report-id= : Process a specific report by ID or UUID (bypasses frequency scheduling)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process automated reports based on their frequency (weekly, monthly, quarterly).
Use --report-id to manually trigger a specific report by ID or UUID.';
public function __construct(
private readonly LoggerInterface $logger,
private readonly BusDispatcher $dispatcher,
private readonly AutomatedReportsRepository $reportRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->logger->info(self::LOG_PREFIX . ' Started');
$now = Carbon::now();
$isMonday = $now->isMonday();
$isFirstDayOfMonth = $now->day === 1;
$currentMonth = $now->month;
// Check if the current month is a quarterly month (January, April, July, October)
$isQuarterlyMonth = in_array($currentMonth, [1, 4, 7, 10], true);
$this->logger->info(self::LOG_PREFIX . ' Checking conditions', [
'isMonday' => $isMonday,
'isFirstDayOfMonth' => $isFirstDayOfMonth,
'currentMonth' => $currentMonth,
'isQuarterlyMonth' => $isQuarterlyMonth,
]);
// Process daily reports
$this->processReports(AutomatedReportsService::FREQUENCY_DAILY);
// Process weekly reports on Mondays
if ($isMonday) {
$this->processReports(AutomatedReportsService::FREQUENCY_WEEKLY);
}
// Process monthly reports on the first day of the month
if ($isFirstDayOfMonth) {
$this->processReports(AutomatedReportsService::FREQUENCY_MONTHLY);
}
// Process quarterly reports on the first day of January, April, July, and October
if ($isFirstDayOfMonth && $isQuarterlyMonth) {
$this->processReports(AutomatedReportsService::FREQUENCY_QUARTERLY);
}
$this->logger->info(self::LOG_PREFIX . ' Completed');
return 0;
}
/**
* Process reports for a specific frequency.
*
* @param string $frequency
*
* @return void
*/
private function processReports(string $frequency): void
{
$this->logger->info(self::LOG_PREFIX . " Processing $frequency reports");
$reportId = $this->option('report-id');
if ($reportId !== null) {
$reports = $this->getReportById($reportId);
} else {
// Get all enabled, not deleted reports with active teams for the specified frequency
$reports = $this->reportRepository->getActiveReportsByFrequency($frequency);
}
$this->logger->info(self::LOG_PREFIX . " Found {$reports->count()} $frequency reports to process");
/** @var AutomatedReport $report */
foreach ($reports as $report) {
$this->logger->info(self::LOG_PREFIX . ' Dispatching Generate Report job for report', [
'reportUuid' => $report->getUuid(),
'teamId' => $report->getTeamId(),
'frequency' => $report->getFrequency(),
'type' => $report->getType(),
]);
$job = $report->isAskJiminnyReport()
? new RequestGenerateAskJiminnyReportJob($report->getUuid())
: new RequestGenerateReportJob($report->getUuid());
$this->dispatcher->dispatch($job);
}
}
private function getReportById(string $reportId): Collection
{
$report = $this->reportRepository->findByIdOrUuid($reportId);
if ($report === null) {
$this->logger->warning(self::LOG_PREFIX . ' Report not found for --report-id', ['reportId' => $reportId]);
$this->warn("Report not found: {$reportId}");
return collect();
}
if (! $report->getStatus()) {
$this->logger->warning(self::LOG_PREFIX . ' Report is inactive, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
]);
$this->warn('Report is inactive — processing anyway (manual override).');
}
$team = $report->getTeam();
if ($team->getStatus() !== Team::STATUS_ACTIVE) {
$this->logger->warning(self::LOG_PREFIX . ' Team is not active, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
'teamId' => $report->getTeamId(),
'teamStatus' => $team->getStatus(),
]);
$this->warn("Team #{$report->getTeamId()} is not active — processing anyway (manual override).");
}
if ($report->isExpired()) {
$this->logger->warning(self::LOG_PREFIX . ' Report is expired, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
'expiresAt' => $report->getExpiresAt()?->toDateString(),
]);
$this->warn('Report is expired (expires_at: ' . $report->getExpiresAt()?->toDateString() . ') — processing anyway (manual override).');
}
$this->info(self::LOG_PREFIX . ' Automated report found ' . $report->getCustomName());
return collect([$report]);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.03046875,"top":0.017361112,"width":0.0453125,"height":0.022222223},"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#11894 on JY-18909-automated-reports-ask-jiminny, menu","depth":5,"bounds":{"left":0.07578125,"top":0.017361112,"width":0.14257812,"height":0.022222223},"help_text":"Pull request #11894 exists for current branch JY-18909-automated-reports-ask-jiminny","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.78515625,"top":0.017361112,"width":0.01328125,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AutomatedReportsCommandTest","depth":6,"bounds":{"left":0.803125,"top":0.017361112,"width":0.09765625,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AutomatedReportsCommandTest'","depth":6,"bounds":{"left":0.9007813,"top":0.017361112,"width":0.01328125,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AutomatedReportsCommandTest'","depth":6,"bounds":{"left":0.9140625,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9273437,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.96015626,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9734375,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9867188,"top":0.017361112,"width":0.013281226,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.5480469,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.5582031,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"bounds":{"left":0.57109374,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"bounds":{"left":0.58125,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"bounds":{"left":0.5914062,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"bounds":{"left":0.60429686,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"bounds":{"left":0.6171875,"top":0.06458333,"width":0.028515626,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"bounds":{"left":0.6484375,"top":0.06458333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"bounds":{"left":0.66132814,"top":0.06458333,"width":0.034765624,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9515625,"top":0.06458333,"width":0.033203125,"height":0.016666668},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.049609374,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"16","depth":4,"bounds":{"left":0.92890626,"top":0.08611111,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"14","depth":4,"bounds":{"left":0.94257814,"top":0.08611111,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"13","depth":4,"bounds":{"left":0.95625,"top":0.08611111,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.96953124,"top":0.08472222,"width":0.00859375,"height":0.015972223},"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.978125,"top":0.08472222,"width":0.008203125,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT * FROM teams WHERE id = 1;\n\nselect * from crm_layouts where crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 283;\nSELECT * FROM crm_fields WHERE id = 2234;\nSELECT * FROM crm_field_values WHERE crm_field_id = 2234;\n\nselect * from crm_profiles where user_id = 143;\n\nselect * from record_types where crm_configuration_id = 39; # 0121K000001MHElQAO,0121K000001MHEqQAO\nselect * from business_processes where crm_configuration_id = 39;\n# 01941000000H669AAC, 01941000000H66JAAS\n\nselect * from record_type_field_values\n where record_type_id IN (24);\n\nselect * from crm_field_values where id IN (2730);\n\nselect * from crm_configurations where id = 39;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce'; #1035\n\n\nselect * from users where team_id = 1; # 222 group 3\nSELECT * FROM activities WHERE user_id = 222 order by id desc;\nselect * from sidekick_settings where team_id = 1;\nselect * from teams where id = 1;\nselect * from team_features where team_id = 1;\n\nselect * from activities where crm_configuration_id = 2\nand provider = 'ms-teams' and id = 608765;\n\nSELECT * FROM activities WHERE crm_configuration_id = 2 and crm_provider_id = '59523413338';\n\nselect * from sidekick_settings where team_id = 2;\n\nSELECT * FROM activities WHERE id = 608660;\nselect * from activity_summary_logs where activity_id = 608660;\nselect * from ai_prompts where transcription_id = 11214;\n\n# ********************************************************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('ed78a437-2804-450e-ab2f-56ab1c641346') = uuid;\n# id: 608818, crm: 59628809737\nSELECT * FROM activities WHERE uuid_to_bin('36b06e55-afdd-4782-8dee-c624cd0af191') = uuid;\n# id: 608821, crm: 59632069252\nSELECT ce.start_time, ce.end_time, a.id, a.uuid, crm_provider_id, calendar_event_id, title,\nplaybook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id,\nscheduled_start_time, scheduled_end_time, actual_start_time, actual_end_time, a.created_at\nFROM activities a\njoin calendar_events ce on a.calendar_event_id = ce.id\nWHERE a.id IN (608818, 608821);\n\nselect * from users where team_id = 1;\nselect * from team_settings where team_id = 1;\nselect * from crm_profiles where crm_configuration_id = 39 order by user_id;\n\nselect * from team_features where team_id = 1;\n\nselect * from users where team_id = 2;\n\nSELECT * FROM activities WHERE uuid_to_bin('ec7647e9-5225-458b-b475-f31aa2769204') = uuid; # 612639\n# Preslava N. Ivanova, grou id 3\n\nSELECT * FROM opportunities WHERE uuid_to_bin('a2928fe5-aec5-46cb-85d9-7654c89e46a6') = uuid;\n\nselect * from activities where opportunity_id = 344 and actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00';\n\nselect\n a.id,\n a.type,\n a.scheduled_start_time,\n a.actual_start_time,\n a.created_at,\n a.opportunity_id,\n a.status\nFROM activities a\nWHERE opportunity_id = 344\nand status IN ('completed', 'received', 'delivered')\nand (\n (a.actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')\nOR (a.created_at between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')\nOR (a.scheduled_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00'))\n;\n\nSELECT * FROM users WHERE id = 222;\n\nSELECT * FROM crm_profiles WHERE user_id = 222;\nselect * from crm_layouts where crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 281;\n\nselect * from group_deal_risk_types;\n\nselect * from opportunities where team_id = 1;\n\nSELECT * FROM opportunities WHERE id = 315;\nSELECT * FROM crm_field_data WHERE object_id = 315;\nselect * from crm_field_data where object_id = 260;\n\nselect * from generic_ai_prompts where subject_id = 315;\n\nselect * from teams; # 36, 21, 121, james.graham@bullhorn.jiminny.com\nSELECT * FROM social_accounts WHERE sociable_id = 121 and provider = 'bullhorn';\n\n# ************************************************************************************\nselect * from teams where id = 1;\nselect * from crm_configurations where id = 39;\nselect * from users where team_id = 1;\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 1;\n# 1 - 00541000004281rAAA\n# 204 - 0052g000003freeAAA\n# 429 - 0052g000003qGOiAAM\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce';\n\nselect * from activities where type = 'softphone'\nand created_at > '2024-12-11 15:24:36' order by id desc;\n\nselect * from activity_providers where team_id = 1;\nselect * from activity_provider_users where activity_provider_id = 328;\n\nselect * from opportunities where crm_configuration_id = 39\nAND account_id = 178 AND is_closed = false\norder by created_at DESC;\n\nselect * from contacts where id = 3952;\nselect * from accounts where id = 178;\n\n# ************************************************************************************\nselect * from teams where id = 36;\nselect * from crm_configurations where id = 21;\nselect * from users where team_id = 36;\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 36;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 36\nand sa.provider = 'bullhorn';\n\nselect * from social_accounts where id = 348;\nUPDATE social_accounts SET\nprovider_user_token = '21442_6802599_91:41179a58-21e7-4d7c-ad58-56bb666b2f65',\nprovider_refresh_token = '21442_6802599_91:01c6b335-3f2a-42e4-85ff-8a08fa65fceb',\nexpires = 1733998131,\nstate = 'connected'\nWHERE id = 348;\n\n# ************************************************************************************\nselect * from teams where id = 31;\nselect * from crm_configurations where id = 18;\n\nselect * from users where team_id = 31; # 257\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 31;\n\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 31\nand sa.provider = 'close';\n\nselect * from contacts where crm_configuration_id = 18;\n\n# ********************** NEPTUNE **************************************************************\nselect * from teams;\nselect * from users where id IN (1030, 1035, 1052);\nselect * from crm_configurations;\n\nselect * from users where team_id = 65; # 257\nselect * from team_settings where team_id = 65; # 257\nselect * from invitations where team_id = 65; # 257\nselect * from users where email = 'integration-account@jiminny.com'; # 257\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 65;\n\nselect * from crm_configurations where id = 53;\nselect * from accounts where crm_configuration_id = 53 order by id desc;\nselect * from leads where crm_configuration_id = 53 order by id desc;\nselect * from contacts where crm_configuration_id = 53 order by id desc;\nselect * from opportunities where crm_configuration_id = 53 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 53 order by id desc;\nselect * from crm_fields where crm_configuration_id = 53 order by id desc;\nselect * from crm_field_values where crm_field_id = 3341 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 53 order by id desc;\nselect * from stages where crm_configuration_id = 53 order by id desc;\n\n\nselect * from crm_profiles where crm_configuration_id = 13;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 65\nand sa.provider = 'integration-app';\n\nselect * from contacts where crm_configuration_id = 13;\n\nselect * from social_accounts where sociable_id = 283;\n\nSELECT * FROM opportunities WHERE crm_provider_id = '006O400000E9bzeIAB';\n\nselect * from activity_providers where team_id = 65;\nSELECT * FROM activities WHERE crm_configuration_id IN (51, 52, 53);\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 65\n;\n\n# ***************************** STAGING ********************************************\nSELECT * FROM teams;\nSELECT * FROM teams WHERE id = 88;\nSELECT * FROM teams WHERE id = 89;\nselect * from team_settings where team_id = 89;\nSELECT * FROM users WHERE team_id = 89;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 89;\n\nselect * from users;\nSELECT * FROM social_accounts WHERE sociable_id = 1761;\nSELECT * FROM crm_configurations WHERE id = 70;\nselect * from accounts where crm_configuration_id = 70 order by id desc;\nselect * from leads where crm_configuration_id = 70 order by id desc;\nselect * from contacts where crm_configuration_id = 70 order by id desc;\nselect * from opportunities where crm_configuration_id = 70 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 70 order by id desc;\nselect * from crm_fields where crm_configuration_id = 70 order by id desc;\nselect * from crm_field_values where crm_field_id = 3536 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 70 order by id desc;\nselect * from stages where crm_configuration_id = 70 order by id desc;\nselect * from business_processes where crm_configuration_id = 70 order by id desc;\nselect * from business_process_stages where business_process_id = 34;\n\nselect * from contacts where id = 10468;\n\nselect * from crm_layouts where crm_configuration_id = 70;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 388;\nSELECT * FROM crm_fields WHERE id IN (3533,3534,3535);\n\nselect * from activities where crm_configuration_id = 70\nand (account_id IS NOT NULL or lead_id IS NOT NULL or contact_id IS NOT NULL or opportunity_id IS NOT NULL) order by id desc;\n\nSELECT * FROM activities WHERE uuid_to_bin('2e10b60f-8a61-41c5-a3d4-28835353dc65') = uuid;\nSELECT * FROM activities where crm_configuration_id = 69 ;\n\nSELECT * FROM users WHERE email LIKE '%jiminny_web_sa2@jiminny.com%';\nSELECT * FROM activities WHERE uuid_to_bin('5a150c93-40fc-42ec-b3bd-c1d328e09f6e') = uuid;\nSELECT * FROM opportunities WHERE id = 385;\n\nselect * from participants p\njoin activities a on p.activity_id = a.id\nwhere a.crm_configuration_id = 70\nand (p.lead_id IS NOT NULL or p.contact_id IS NOT NULL);\nSELECT * FROM participants WHERE id = 1013638;\n\nselect * from teams where id = 90;\nselect * from users where team_id = 90;\nselect * from social_accounts where social_accounts.sociable_id IN (1960,1760);\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 71;\nselect * from invitations where team_id = 90;\n\nselect * from crm_configurations where id = 71;\nselect * from accounts where crm_configuration_id = 71 order by id desc;\nselect * from leads where crm_configuration_id = 71 order by id desc;\nselect * from contacts where crm_configuration_id = 71 order by id desc;\nselect * from opportunities where crm_configuration_id = 71 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 71 order by id desc;\nselect * from crm_fields where crm_configuration_id = 71 order by id desc;\nselect * from crm_field_values where crm_field_id = 3341 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 71 order by id desc;\nselect * from stages where crm_configuration_id = 71 order by id desc;\n\nselect * from users order by secondary_email desc;\nselect u.id, u.email, u.status, sa.id, sa.provider_user_id from social_accounts sa\n join users u on sa.sociable_id = u.id\nwhere sa.provider = 'google' and u.email LIKE 'aneliya%';\n\nselect * from failed_jobs order by id desc;\n\nselect * from users where email = 'ben.allwright@learningpeople.co.uk' or secondary_email = 'ben.allwright@learningpeople.co.uk';\n\nselect * from teams;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 39;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type = 'task';\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce';\n\n# ************************************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('c38b3895-fd0f-4b1f-9fb2-c170dba137c6') = uuid;\nSELECT * FROM crm_configurations WHERE id = 70;\n\nselect * from teams where id = 1;\nselect * from groups where team_id = 1;\nselect * from users where team_id = 1;\n\nselect o.id, o.name,o.close_date, u.id, u.name, u.group_id, r.id, r.display_name, g.name, g.scope from opportunities o\njoin users u on o.user_id = u.id\njoin groups g on u.group_id = g.id\njoin role_user ru on u.id = ru.user_id\njoin roles r on ru.role_id = r.id\nwhere o.crm_configuration_id = 39 and close_date > '2024-01-01 00:00:00';\n\nselect * from role_user where user_id = 143;\nselect * from roles;\n\nselect * from role_user;\nselect * from groups where id = 9;\nselect * from scope_groups where group_id = 9;\n\n# ************************************************************************************\nselect * from teams where id = 36;\nselect * from crm_configurations;\nSELECT * FROM social_accounts WHERE sociable_id = 121;\n\nhttps://crmsandbox.zoho.com/crm/jiminnyw4/tab/Leads/4776201000005049105\nhttps://crmsandbox.zoho.com/crm/\n\nhttps://crm.zoho.com/crm/org3469620/tab/Leads/230045000229559080\n https://crm.zoho.com/crm/\n org3469620\n\nSELECT * FROM activities WHERE uuid_to_bin('03382d20-c8bc-48e7-a3d4-90b52fa5ceab') = uuid;\n\nselect * from users where email LIKE \"%mobile_automation_%\";\nselect * from social_accounts where sociable_id IN (2228);\nselect * from crm_profiles where user_id IN (2222,2223,2226,2227);\n\nselect * from teams order by id desc;\nSELECT * FROM users WHERE id = 2229;\nSELECT * FROM crm_profiles WHERE user_id = 2229;\nselect * from opportunities where crm_configuration_id = 88;\nselect * from crm_fields where crm_configuration_id = 88;\nselect * from crm_profiles where crm_configuration_id = 88;\n\nSELECT * FROM teams WHERE id = 1;\n\nSELECT * FROM users WHERE id = 143;\nSELECT * FROM users WHERE uuid_to_bin('fde193d3-06a2-4e1a-8895-62b94039215d') = uuid;\nSELECT * FROM teams WHERE uuid_to_bin('73385071-a756-42ae-9c73-8b53f2309467') = uuid;\n\nhttps://app.staging.jiminny.com/ondemand?\n min_duration=1\n &\n only_recorded=1\n &\n user_id%5B%5D=641f1acb-16b8-42d1-8726-df52979dad0e\n &\n sequence_number=2\n\n select * from users where team_id = 1 and email like '%stoyan%'\n\nselect * from coaching_feedbacks;\n\nselect * from teams;\nSELECT * FROM users WHERE team_id = 36;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 19\nand sa.provider = 'pipedrive';\n\nselect * from users where id = 143;\n\nSELECT * FROM users WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\nSELECT * FROM teams WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\nSELECT * FROM activity_shares WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\n\nselect * from users where team_id = 2;\nselect * from activities where crm_configuration_id = 39\nand activities.scheduled_start_time BETWEEN '2025-04-09 00:00:00' AND '2025-04-09 23:59:59'\nAND user_id = 143\norder by id desc;\n\n# ************************************************************************************\nselect * from teams where id = 142; # 2312, 126\nselect * from team_settings;\nselect * from users where team_id = 142; # 21642\nSELECT * FROM social_accounts WHERE sociable_id = 21642;\nSELECT * FROM crm_profiles cp join users u ON u.id = cp.user_id WHERE team_id = 142;\nselect * from crm_profiles where id IN (93);\nselect * from invitations;\nselect * from team_features where team_id = 1;\n\nSELECT * FROM crm_configurations WHERE id = 126;\nselect * from accounts where crm_configuration_id = 126 order by id desc;\nselect * from leads where crm_configuration_id = 126 order by id desc;\nselect * from contacts where crm_configuration_id = 126 order by id desc;\nselect * from opportunities where crm_configuration_id = 126 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 126 order by id desc;\nselect * from crm_fields where crm_configuration_id = 126 # 11060\n# and type IN ('picklist', 'status')\n# and object_type = 'task'\norder by id desc;\n# 5731,5732,5733\nselect DISTINCT crm_field_id from crm_field_values where crm_field_id IN (11151,12239,12215,12185,12175,12165,12144,12137,12127,12109,12107,12105,12103,12092,12037,12005,12003,11987,11969,11958,11951,11942,11931,11924,11921,11917,11915,11901,11893,11883,11872,11870,11868,11866,11839,11833,11821,11793,11780,11777,11769,11757,11737,11735,11656,11645,11638,11629,11618,11611,11602,11591,11584,11581,11558,11544,11543,11534,11532,11529,11527,11503,11497,11493,11488,11470,11468,11457,11455,11397,11387,11372,11363,11348,11323,11318,11309,11301,11300,11292,11290,11286,11284,11256,11252,11242,11237,11233,11219,11176,11160) order by id desc;\nselect * from crm_layouts where crm_configuration_id = 126 order by id desc;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id in (300,299,298);\nselect * from stages where crm_configuration_id = 126 order by id desc;\nselect * from business_processes where crm_configuration_id = 126 order by id desc;\nselect * from business_process_stages where business_process_id IN (76,75,74,73);\nselect * from playbooks where team_id = 142;\nselect * from playbook_layouts where playbook_id IN (108);\nSELECT * FROM playbook_categories WHERE playbook_id IN (108);\n\nselect * from teams where id = 130;\nSELECT * FROM social_accounts WHERE sociable_id = 2291;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 2\nand sa.provider = 'hubspot';\n\nSELECT * FROM activities\n WHERE crm_configuration_id = 110;\n\nselect * from teams;\nselect * from crm_configurations;\n\nSELECT * FROM activities WHERE id = 628773;\nSELECT * FROM crm_profiles WHERE user_id = 1460;\nSELECT * FROM social_accounts WHERE sociable_id = 2291;\n\nselect * from teams;\nselect ru.*, pr.*, p.* from users u join role_user ru on ru.user_id = u.id\njoin permission_role pr on pr.role_id = ru.role_id\n join permissions p on p.id = pr.permission_id\nwhere team_id = 495 and p.name IN ('dial');\n\nselect * from teams where id = 145;\nselect * from crm_configurations where id = 129;\nselect * from social_accounts where sociable_id = 2317;\nSELECT * FROM activities WHERE uuid_to_bin('8dbab184-a333-4268-ad57-fb41f8d53a9a') = uuid;\n\nselect * from teams where id = 1;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 280;\nSELECT * FROM crm_layout_entities WHERE id = 5507;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type IN ('event');\n\nselect * from teams;\nselect * from activities where crm_configuration_id = 14;\n\nSELECT * FROM social_accounts where provider = 'copper';\n\nselect * from activities where id = 628467;\nselect * from participants where activity_id = 628467;\n\nSELECT * FROM contacts WHERE id = 3969;\nSELECT * FROM accounts WHERE id = 177;\n\nSELECT * FROM activities WHERE uuid_to_bin('4eb54c77-cfa3-2bd4-84a7-9ed46a21c988') = uuid;\n\n# ********************* BH\nselect * from teams where id = 36;\nSELECT * FROM crm_configurations WHERE id = 21;\nselect * from activities where crm_configuration_id = 21 and id = 607901;\nselect * from activities where crm_configuration_id = 21;\n\nselect * roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 226;\n\nselect * from migrations order by id desc;\n\n# mercury\n# neptune\n# earth\n\nselect * from teams;\nselect * from teams where id = 19;\nselect * from teams where id = 27;\nselect * from users where team_id = 27;\nSELECT * FROM crm_configurations WHERE id = 42;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 19\nand sa.provider = 'pipedrive';\n\nselect * from activities where id = 631461;\nSELECT * FROM crm_field_values WHERE crm_field_id = 180;\n\nselect * from teams where id = 2;\nSELECT * FROM social_accounts WHERE sociable_id = 89;\n\nSELECT * FROM activities WHERE uuid_to_bin('ba0c029a-bc14-4e17-8603-64174acebcbb') = uuid; # 634273\nselect * from activity_summary_logs where activity_id = 634273;\n\nselect * from sidekick_settings where team_id = 2;\n\nselect * from teams; # 2, 2\nSELECT * FROM crm_configurations WHERE team_id = 2; # 2\nselect * from team_features where team_id = 2;\nselect * from features;\nSELECT * FROM opportunities WHERE crm_configuration_id = 2 and crm_provider_id = '51317301383';\nSELECT * FROM opportunities WHERE crm_configuration_id = 2 order by id desc;\n\nselect * from automated_reports order by id desc;\nselect * from automated_report_results order by id desc;\nselect * from users where team_id = 1 and id IN (7160, 3248);\nselect * from migrations order by id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1;\nselect * from groups g JOIN playbooks p on g.playbook_id = p.id where g.team_id = 1;\nselect * from groups where id = 565;\nselect * from playbooks where team_id = 1;\nselect * from playbooks where id = 175;\nselect * from playbook_categories where playbook_id = 175;\nselect * from users where team_id = 1;\nselect * from users where id = 7160;\nselect * from crm_profiles where user_id = 7160;\nselect * from features;\nselect\n *\n# id, uuid, type, provider, playbook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id, stage_id,\n# crm_configuration_id, crm_provider_id, transcription_id, status\nfrom activities where crm_configuration_id = 1 and type = 'conference'\n# and crm_provider_id IS NOT NULL\nand provider != 'uploader' and actual_start_time IS NOT NULL\nORDER by id desc;\nselect * from activities where id = 54747783; # 00UO400000pCzojMAC\n\nselect p.id, p.activity_type, pc.id, pc.name\nFROM playbooks p\njoin playbook_categories pc on p.id = pc.playbook_id\nwhere p.team_id = 1 and p.activity_type = 'event';\n\nSELECT * FROM crm_fields WHERE crm_configuration_id = 1 and object_type = 'event';\nSELECT * FROM crm_field_values WHERE crm_field_id = 4;\n\nselect * from crm_layouts cl join playbook_layouts pl on cl.id = pl.layout_id\nwhere crm_configuration_id = 1 and pl.playbook_id = 175;\n\nselect * from teams;\nSELECT r.* FROM automated_reports r\njoin teams t on r.team_id = t.id\nWHERE r.frequency = 'daily'\n and r.status = 1\nAND t.status = 'active'\nAND (r.expires_at >= now() OR r.expires_at IS NULL);\n\nselect * from automated_report_results where report_id IN (18, 33);\n\nselect * from activity_searches where id = 10932;\nselect * from activity_search_filters where activity_search_id = 10932;\nselect * from automated_reports order by id desc;\nselect * from automated_report_results where report_id IN (36);","depth":4,"value":"SELECT * FROM teams WHERE id = 1;\n\nselect * from crm_layouts where crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 283;\nSELECT * FROM crm_fields WHERE id = 2234;\nSELECT * FROM crm_field_values WHERE crm_field_id = 2234;\n\nselect * from crm_profiles where user_id = 143;\n\nselect * from record_types where crm_configuration_id = 39; # 0121K000001MHElQAO,0121K000001MHEqQAO\nselect * from business_processes where crm_configuration_id = 39;\n# 01941000000H669AAC, 01941000000H66JAAS\n\nselect * from record_type_field_values\n where record_type_id IN (24);\n\nselect * from crm_field_values where id IN (2730);\n\nselect * from crm_configurations where id = 39;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce'; #1035\n\n\nselect * from users where team_id = 1; # 222 group 3\nSELECT * FROM activities WHERE user_id = 222 order by id desc;\nselect * from sidekick_settings where team_id = 1;\nselect * from teams where id = 1;\nselect * from team_features where team_id = 1;\n\nselect * from activities where crm_configuration_id = 2\nand provider = 'ms-teams' and id = 608765;\n\nSELECT * FROM activities WHERE crm_configuration_id = 2 and crm_provider_id = '59523413338';\n\nselect * from sidekick_settings where team_id = 2;\n\nSELECT * FROM activities WHERE id = 608660;\nselect * from activity_summary_logs where activity_id = 608660;\nselect * from ai_prompts where transcription_id = 11214;\n\n# ********************************************************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('ed78a437-2804-450e-ab2f-56ab1c641346') = uuid;\n# id: 608818, crm: 59628809737\nSELECT * FROM activities WHERE uuid_to_bin('36b06e55-afdd-4782-8dee-c624cd0af191') = uuid;\n# id: 608821, crm: 59632069252\nSELECT ce.start_time, ce.end_time, a.id, a.uuid, crm_provider_id, calendar_event_id, title,\nplaybook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id,\nscheduled_start_time, scheduled_end_time, actual_start_time, actual_end_time, a.created_at\nFROM activities a\njoin calendar_events ce on a.calendar_event_id = ce.id\nWHERE a.id IN (608818, 608821);\n\nselect * from users where team_id = 1;\nselect * from team_settings where team_id = 1;\nselect * from crm_profiles where crm_configuration_id = 39 order by user_id;\n\nselect * from team_features where team_id = 1;\n\nselect * from users where team_id = 2;\n\nSELECT * FROM activities WHERE uuid_to_bin('ec7647e9-5225-458b-b475-f31aa2769204') = uuid; # 612639\n# Preslava N. Ivanova, grou id 3\n\nSELECT * FROM opportunities WHERE uuid_to_bin('a2928fe5-aec5-46cb-85d9-7654c89e46a6') = uuid;\n\nselect * from activities where opportunity_id = 344 and actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00';\n\nselect\n a.id,\n a.type,\n a.scheduled_start_time,\n a.actual_start_time,\n a.created_at,\n a.opportunity_id,\n a.status\nFROM activities a\nWHERE opportunity_id = 344\nand status IN ('completed', 'received', 'delivered')\nand (\n (a.actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')\nOR (a.created_at between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')\nOR (a.scheduled_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00'))\n;\n\nSELECT * FROM users WHERE id = 222;\n\nSELECT * FROM crm_profiles WHERE user_id = 222;\nselect * from crm_layouts where crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 281;\n\nselect * from group_deal_risk_types;\n\nselect * from opportunities where team_id = 1;\n\nSELECT * FROM opportunities WHERE id = 315;\nSELECT * FROM crm_field_data WHERE object_id = 315;\nselect * from crm_field_data where object_id = 260;\n\nselect * from generic_ai_prompts where subject_id = 315;\n\nselect * from teams; # 36, 21, 121, james.graham@bullhorn.jiminny.com\nSELECT * FROM social_accounts WHERE sociable_id = 121 and provider = 'bullhorn';\n\n# ************************************************************************************\nselect * from teams where id = 1;\nselect * from crm_configurations where id = 39;\nselect * from users where team_id = 1;\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 1;\n# 1 - 00541000004281rAAA\n# 204 - 0052g000003freeAAA\n# 429 - 0052g000003qGOiAAM\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce';\n\nselect * from activities where type = 'softphone'\nand created_at > '2024-12-11 15:24:36' order by id desc;\n\nselect * from activity_providers where team_id = 1;\nselect * from activity_provider_users where activity_provider_id = 328;\n\nselect * from opportunities where crm_configuration_id = 39\nAND account_id = 178 AND is_closed = false\norder by created_at DESC;\n\nselect * from contacts where id = 3952;\nselect * from accounts where id = 178;\n\n# ************************************************************************************\nselect * from teams where id = 36;\nselect * from crm_configurations where id = 21;\nselect * from users where team_id = 36;\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 36;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 36\nand sa.provider = 'bullhorn';\n\nselect * from social_accounts where id = 348;\nUPDATE social_accounts SET\nprovider_user_token = '21442_6802599_91:41179a58-21e7-4d7c-ad58-56bb666b2f65',\nprovider_refresh_token = '21442_6802599_91:01c6b335-3f2a-42e4-85ff-8a08fa65fceb',\nexpires = 1733998131,\nstate = 'connected'\nWHERE id = 348;\n\n# ************************************************************************************\nselect * from teams where id = 31;\nselect * from crm_configurations where id = 18;\n\nselect * from users where team_id = 31; # 257\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 31;\n\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 31\nand sa.provider = 'close';\n\nselect * from contacts where crm_configuration_id = 18;\n\n# ********************** NEPTUNE **************************************************************\nselect * from teams;\nselect * from users where id IN (1030, 1035, 1052);\nselect * from crm_configurations;\n\nselect * from users where team_id = 65; # 257\nselect * from team_settings where team_id = 65; # 257\nselect * from invitations where team_id = 65; # 257\nselect * from users where email = 'integration-account@jiminny.com'; # 257\nselect u.email, cp.* from users u\njoin crm_profiles cp on u.id = cp.user_id\nwhere u.team_id = 65;\n\nselect * from crm_configurations where id = 53;\nselect * from accounts where crm_configuration_id = 53 order by id desc;\nselect * from leads where crm_configuration_id = 53 order by id desc;\nselect * from contacts where crm_configuration_id = 53 order by id desc;\nselect * from opportunities where crm_configuration_id = 53 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 53 order by id desc;\nselect * from crm_fields where crm_configuration_id = 53 order by id desc;\nselect * from crm_field_values where crm_field_id = 3341 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 53 order by id desc;\nselect * from stages where crm_configuration_id = 53 order by id desc;\n\n\nselect * from crm_profiles where crm_configuration_id = 13;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 65\nand sa.provider = 'integration-app';\n\nselect * from contacts where crm_configuration_id = 13;\n\nselect * from social_accounts where sociable_id = 283;\n\nSELECT * FROM opportunities WHERE crm_provider_id = '006O400000E9bzeIAB';\n\nselect * from activity_providers where team_id = 65;\nSELECT * FROM activities WHERE crm_configuration_id IN (51, 52, 53);\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 65\n;\n\n# ***************************** STAGING ********************************************\nSELECT * FROM teams;\nSELECT * FROM teams WHERE id = 88;\nSELECT * FROM teams WHERE id = 89;\nselect * from team_settings where team_id = 89;\nSELECT * FROM users WHERE team_id = 89;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 89;\n\nselect * from users;\nSELECT * FROM social_accounts WHERE sociable_id = 1761;\nSELECT * FROM crm_configurations WHERE id = 70;\nselect * from accounts where crm_configuration_id = 70 order by id desc;\nselect * from leads where crm_configuration_id = 70 order by id desc;\nselect * from contacts where crm_configuration_id = 70 order by id desc;\nselect * from opportunities where crm_configuration_id = 70 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 70 order by id desc;\nselect * from crm_fields where crm_configuration_id = 70 order by id desc;\nselect * from crm_field_values where crm_field_id = 3536 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 70 order by id desc;\nselect * from stages where crm_configuration_id = 70 order by id desc;\nselect * from business_processes where crm_configuration_id = 70 order by id desc;\nselect * from business_process_stages where business_process_id = 34;\n\nselect * from contacts where id = 10468;\n\nselect * from crm_layouts where crm_configuration_id = 70;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 388;\nSELECT * FROM crm_fields WHERE id IN (3533,3534,3535);\n\nselect * from activities where crm_configuration_id = 70\nand (account_id IS NOT NULL or lead_id IS NOT NULL or contact_id IS NOT NULL or opportunity_id IS NOT NULL) order by id desc;\n\nSELECT * FROM activities WHERE uuid_to_bin('2e10b60f-8a61-41c5-a3d4-28835353dc65') = uuid;\nSELECT * FROM activities where crm_configuration_id = 69 ;\n\nSELECT * FROM users WHERE email LIKE '%jiminny_web_sa2@jiminny.com%';\nSELECT * FROM activities WHERE uuid_to_bin('5a150c93-40fc-42ec-b3bd-c1d328e09f6e') = uuid;\nSELECT * FROM opportunities WHERE id = 385;\n\nselect * from participants p\njoin activities a on p.activity_id = a.id\nwhere a.crm_configuration_id = 70\nand (p.lead_id IS NOT NULL or p.contact_id IS NOT NULL);\nSELECT * FROM participants WHERE id = 1013638;\n\nselect * from teams where id = 90;\nselect * from users where team_id = 90;\nselect * from social_accounts where social_accounts.sociable_id IN (1960,1760);\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 71;\nselect * from invitations where team_id = 90;\n\nselect * from crm_configurations where id = 71;\nselect * from accounts where crm_configuration_id = 71 order by id desc;\nselect * from leads where crm_configuration_id = 71 order by id desc;\nselect * from contacts where crm_configuration_id = 71 order by id desc;\nselect * from opportunities where crm_configuration_id = 71 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 71 order by id desc;\nselect * from crm_fields where crm_configuration_id = 71 order by id desc;\nselect * from crm_field_values where crm_field_id = 3341 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 71 order by id desc;\nselect * from stages where crm_configuration_id = 71 order by id desc;\n\nselect * from users order by secondary_email desc;\nselect u.id, u.email, u.status, sa.id, sa.provider_user_id from social_accounts sa\n join users u on sa.sociable_id = u.id\nwhere sa.provider = 'google' and u.email LIKE 'aneliya%';\n\nselect * from failed_jobs order by id desc;\n\nselect * from users where email = 'ben.allwright@learningpeople.co.uk' or secondary_email = 'ben.allwright@learningpeople.co.uk';\n\nselect * from teams;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 39;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type = 'task';\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 1\nand sa.provider = 'salesforce';\n\n# ************************************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('c38b3895-fd0f-4b1f-9fb2-c170dba137c6') = uuid;\nSELECT * FROM crm_configurations WHERE id = 70;\n\nselect * from teams where id = 1;\nselect * from groups where team_id = 1;\nselect * from users where team_id = 1;\n\nselect o.id, o.name,o.close_date, u.id, u.name, u.group_id, r.id, r.display_name, g.name, g.scope from opportunities o\njoin users u on o.user_id = u.id\njoin groups g on u.group_id = g.id\njoin role_user ru on u.id = ru.user_id\njoin roles r on ru.role_id = r.id\nwhere o.crm_configuration_id = 39 and close_date > '2024-01-01 00:00:00';\n\nselect * from role_user where user_id = 143;\nselect * from roles;\n\nselect * from role_user;\nselect * from groups where id = 9;\nselect * from scope_groups where group_id = 9;\n\n# ************************************************************************************\nselect * from teams where id = 36;\nselect * from crm_configurations;\nSELECT * FROM social_accounts WHERE sociable_id = 121;\n\nhttps://crmsandbox.zoho.com/crm/jiminnyw4/tab/Leads/4776201000005049105\nhttps://crmsandbox.zoho.com/crm/\n\nhttps://crm.zoho.com/crm/org3469620/tab/Leads/230045000229559080\n https://crm.zoho.com/crm/\n org3469620\n\nSELECT * FROM activities WHERE uuid_to_bin('03382d20-c8bc-48e7-a3d4-90b52fa5ceab') = uuid;\n\nselect * from users where email LIKE \"%mobile_automation_%\";\nselect * from social_accounts where sociable_id IN (2228);\nselect * from crm_profiles where user_id IN (2222,2223,2226,2227);\n\nselect * from teams order by id desc;\nSELECT * FROM users WHERE id = 2229;\nSELECT * FROM crm_profiles WHERE user_id = 2229;\nselect * from opportunities where crm_configuration_id = 88;\nselect * from crm_fields where crm_configuration_id = 88;\nselect * from crm_profiles where crm_configuration_id = 88;\n\nSELECT * FROM teams WHERE id = 1;\n\nSELECT * FROM users WHERE id = 143;\nSELECT * FROM users WHERE uuid_to_bin('fde193d3-06a2-4e1a-8895-62b94039215d') = uuid;\nSELECT * FROM teams WHERE uuid_to_bin('73385071-a756-42ae-9c73-8b53f2309467') = uuid;\n\nhttps://app.staging.jiminny.com/ondemand?\n min_duration=1\n &\n only_recorded=1\n &\n user_id%5B%5D=641f1acb-16b8-42d1-8726-df52979dad0e\n &\n sequence_number=2\n\n select * from users where team_id = 1 and email like '%stoyan%'\n\nselect * from coaching_feedbacks;\n\nselect * from teams;\nSELECT * FROM users WHERE team_id = 36;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 19\nand sa.provider = 'pipedrive';\n\nselect * from users where id = 143;\n\nSELECT * FROM users WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\nSELECT * FROM teams WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\nSELECT * FROM activity_shares WHERE uuid_to_bin('73180eeb-33de-4065-977d-ccbe0e6c94fc') = uuid;\n\nselect * from users where team_id = 2;\nselect * from activities where crm_configuration_id = 39\nand activities.scheduled_start_time BETWEEN '2025-04-09 00:00:00' AND '2025-04-09 23:59:59'\nAND user_id = 143\norder by id desc;\n\n# ************************************************************************************\nselect * from teams where id = 142; # 2312, 126\nselect * from team_settings;\nselect * from users where team_id = 142; # 21642\nSELECT * FROM social_accounts WHERE sociable_id = 21642;\nSELECT * FROM crm_profiles cp join users u ON u.id = cp.user_id WHERE team_id = 142;\nselect * from crm_profiles where id IN (93);\nselect * from invitations;\nselect * from team_features where team_id = 1;\n\nSELECT * FROM crm_configurations WHERE id = 126;\nselect * from accounts where crm_configuration_id = 126 order by id desc;\nselect * from leads where crm_configuration_id = 126 order by id desc;\nselect * from contacts where crm_configuration_id = 126 order by id desc;\nselect * from opportunities where crm_configuration_id = 126 order by id desc;\nselect * from crm_profiles where crm_configuration_id = 126 order by id desc;\nselect * from crm_fields where crm_configuration_id = 126 # 11060\n# and type IN ('picklist', 'status')\n# and object_type = 'task'\norder by id desc;\n# 5731,5732,5733\nselect DISTINCT crm_field_id from crm_field_values where crm_field_id IN (11151,12239,12215,12185,12175,12165,12144,12137,12127,12109,12107,12105,12103,12092,12037,12005,12003,11987,11969,11958,11951,11942,11931,11924,11921,11917,11915,11901,11893,11883,11872,11870,11868,11866,11839,11833,11821,11793,11780,11777,11769,11757,11737,11735,11656,11645,11638,11629,11618,11611,11602,11591,11584,11581,11558,11544,11543,11534,11532,11529,11527,11503,11497,11493,11488,11470,11468,11457,11455,11397,11387,11372,11363,11348,11323,11318,11309,11301,11300,11292,11290,11286,11284,11256,11252,11242,11237,11233,11219,11176,11160) order by id desc;\nselect * from crm_layouts where crm_configuration_id = 126 order by id desc;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id in (300,299,298);\nselect * from stages where crm_configuration_id = 126 order by id desc;\nselect * from business_processes where crm_configuration_id = 126 order by id desc;\nselect * from business_process_stages where business_process_id IN (76,75,74,73);\nselect * from playbooks where team_id = 142;\nselect * from playbook_layouts where playbook_id IN (108);\nSELECT * FROM playbook_categories WHERE playbook_id IN (108);\n\nselect * from teams where id = 130;\nSELECT * FROM social_accounts WHERE sociable_id = 2291;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 2\nand sa.provider = 'hubspot';\n\nSELECT * FROM activities\n WHERE crm_configuration_id = 110;\n\nselect * from teams;\nselect * from crm_configurations;\n\nSELECT * FROM activities WHERE id = 628773;\nSELECT * FROM crm_profiles WHERE user_id = 1460;\nSELECT * FROM social_accounts WHERE sociable_id = 2291;\n\nselect * from teams;\nselect ru.*, pr.*, p.* from users u join role_user ru on ru.user_id = u.id\njoin permission_role pr on pr.role_id = ru.role_id\n join permissions p on p.id = pr.permission_id\nwhere team_id = 495 and p.name IN ('dial');\n\nselect * from teams where id = 145;\nselect * from crm_configurations where id = 129;\nselect * from social_accounts where sociable_id = 2317;\nSELECT * FROM activities WHERE uuid_to_bin('8dbab184-a333-4268-ad57-fb41f8d53a9a') = uuid;\n\nselect * from teams where id = 1;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 39;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 280;\nSELECT * FROM crm_layout_entities WHERE id = 5507;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type IN ('event');\n\nselect * from teams;\nselect * from activities where crm_configuration_id = 14;\n\nSELECT * FROM social_accounts where provider = 'copper';\n\nselect * from activities where id = 628467;\nselect * from participants where activity_id = 628467;\n\nSELECT * FROM contacts WHERE id = 3969;\nSELECT * FROM accounts WHERE id = 177;\n\nSELECT * FROM activities WHERE uuid_to_bin('4eb54c77-cfa3-2bd4-84a7-9ed46a21c988') = uuid;\n\n# ********************* BH\nselect * from teams where id = 36;\nSELECT * FROM crm_configurations WHERE id = 21;\nselect * from activities where crm_configuration_id = 21 and id = 607901;\nselect * from activities where crm_configuration_id = 21;\n\nselect * roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 226;\n\nselect * from migrations order by id desc;\n\n# mercury\n# neptune\n# earth\n\nselect * from teams;\nselect * from teams where id = 19;\nselect * from teams where id = 27;\nselect * from users where team_id = 27;\nSELECT * FROM crm_configurations WHERE id = 42;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 19\nand sa.provider = 'pipedrive';\n\nselect * from activities where id = 631461;\nSELECT * FROM crm_field_values WHERE crm_field_id = 180;\n\nselect * from teams where id = 2;\nSELECT * FROM social_accounts WHERE sociable_id = 89;\n\nSELECT * FROM activities WHERE uuid_to_bin('ba0c029a-bc14-4e17-8603-64174acebcbb') = uuid; # 634273\nselect * from activity_summary_logs where activity_id = 634273;\n\nselect * from sidekick_settings where team_id = 2;\n\nselect * from teams; # 2, 2\nSELECT * FROM crm_configurations WHERE team_id = 2; # 2\nselect * from team_features where team_id = 2;\nselect * from features;\nSELECT * FROM opportunities WHERE crm_configuration_id = 2 and crm_provider_id = '51317301383';\nSELECT * FROM opportunities WHERE crm_configuration_id = 2 order by id desc;\n\nselect * from automated_reports order by id desc;\nselect * from automated_report_results order by id desc;\nselect * from users where team_id = 1 and id IN (7160, 3248);\nselect * from migrations order by id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1;\nselect * from groups g JOIN playbooks p on g.playbook_id = p.id where g.team_id = 1;\nselect * from groups where id = 565;\nselect * from playbooks where team_id = 1;\nselect * from playbooks where id = 175;\nselect * from playbook_categories where playbook_id = 175;\nselect * from users where team_id = 1;\nselect * from users where id = 7160;\nselect * from crm_profiles where user_id = 7160;\nselect * from features;\nselect\n *\n# id, uuid, type, provider, playbook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id, stage_id,\n# crm_configuration_id, crm_provider_id, transcription_id, status\nfrom activities where crm_configuration_id = 1 and type = 'conference'\n# and crm_provider_id IS NOT NULL\nand provider != 'uploader' and actual_start_time IS NOT NULL\nORDER by id desc;\nselect * from activities where id = 54747783; # 00UO400000pCzojMAC\n\nselect p.id, p.activity_type, pc.id, pc.name\nFROM playbooks p\njoin playbook_categories pc on p.id = pc.playbook_id\nwhere p.team_id = 1 and p.activity_type = 'event';\n\nSELECT * FROM crm_fields WHERE crm_configuration_id = 1 and object_type = 'event';\nSELECT * FROM crm_field_values WHERE crm_field_id = 4;\n\nselect * from crm_layouts cl join playbook_layouts pl on cl.id = pl.layout_id\nwhere crm_configuration_id = 1 and pl.playbook_id = 175;\n\nselect * from teams;\nSELECT r.* FROM automated_reports r\njoin teams t on r.team_id = t.id\nWHERE r.frequency = 'daily'\n and r.status = 1\nAND t.status = 'active'\nAND (r.expires_at >= now() OR r.expires_at IS NULL);\n\nselect * from automated_report_results where report_id IN (18, 33);\n\nselect * from activity_searches where id = 10932;\nselect * from activity_search_filters where activity_search_id = 10932;\nselect * from automated_reports order by id desc;\nselect * from automated_report_results where report_id IN (36);","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.049609374,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.509375,"top":0.15208334,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.52070314,"top":0.15069444,"width":0.00859375,"height":0.015972223},"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.5292969,"top":0.15069444,"width":0.008203125,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Console\\Commands\\Reports;\n\nuse Carbon\\Carbon;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Contracts\\Bus\\Dispatcher as BusDispatcher;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Jobs\\AutomatedReports\\RequestGenerateAskJiminnyReportJob;\nuse Jiminny\\Jobs\\AutomatedReports\\RequestGenerateReportJob;\nuse Jiminny\\Models\\AutomatedReport;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Repositories\\AutomatedReportsRepository;\nuse Jiminny\\Services\\Kiosk\\AutomatedReports\\AutomatedReportsService;\nuse Psr\\Log\\LoggerInterface;\n\nclass AutomatedReportsCommand extends Command\n{\n /**\n * Log prefix for all log messages\n */\n private const string LOG_PREFIX = '[automated-reports]';\n\n /**\n * The name and signature of the console command.\n *\n * @var string\n */\n protected $signature = 'automated-reports \n {--report-id= : Process a specific report by ID or UUID (bypasses frequency scheduling)}';\n\n /**\n * The console command description.\n *\n * @var string\n */\n protected $description = 'Process automated reports based on their frequency (weekly, monthly, quarterly). \n Use --report-id to manually trigger a specific report by ID or UUID.';\n\n\n public function __construct(\n private readonly LoggerInterface $logger,\n private readonly BusDispatcher $dispatcher,\n private readonly AutomatedReportsRepository $reportRepository\n ) {\n parent::__construct();\n }\n\n /**\n * Execute the console command.\n *\n * @return int\n */\n public function handle(): int\n {\n $this->logger->info(self::LOG_PREFIX . ' Started');\n\n $now = Carbon::now();\n $isMonday = $now->isMonday();\n $isFirstDayOfMonth = $now->day === 1;\n $currentMonth = $now->month;\n\n // Check if the current month is a quarterly month (January, April, July, October)\n $isQuarterlyMonth = in_array($currentMonth, [1, 4, 7, 10], true);\n\n $this->logger->info(self::LOG_PREFIX . ' Checking conditions', [\n 'isMonday' => $isMonday,\n 'isFirstDayOfMonth' => $isFirstDayOfMonth,\n 'currentMonth' => $currentMonth,\n 'isQuarterlyMonth' => $isQuarterlyMonth,\n ]);\n\n // Process daily reports\n $this->processReports(AutomatedReportsService::FREQUENCY_DAILY);\n\n // Process weekly reports on Mondays\n if ($isMonday) {\n $this->processReports(AutomatedReportsService::FREQUENCY_WEEKLY);\n }\n\n // Process monthly reports on the first day of the month\n if ($isFirstDayOfMonth) {\n $this->processReports(AutomatedReportsService::FREQUENCY_MONTHLY);\n }\n\n // Process quarterly reports on the first day of January, April, July, and October\n if ($isFirstDayOfMonth && $isQuarterlyMonth) {\n $this->processReports(AutomatedReportsService::FREQUENCY_QUARTERLY);\n }\n\n $this->logger->info(self::LOG_PREFIX . ' Completed');\n\n return 0;\n }\n\n /**\n * Process reports for a specific frequency.\n *\n * @param string $frequency\n *\n * @return void\n */\n private function processReports(string $frequency): void\n {\n $this->logger->info(self::LOG_PREFIX . \" Processing $frequency reports\");\n\n $reportId = $this->option('report-id');\n if ($reportId !== null) {\n $reports = $this->getReportById($reportId);\n } else {\n // Get all enabled, not deleted reports with active teams for the specified frequency\n $reports = $this->reportRepository->getActiveReportsByFrequency($frequency);\n }\n\n $this->logger->info(self::LOG_PREFIX . \" Found {$reports->count()} $frequency reports to process\");\n\n /** @var AutomatedReport $report */\n foreach ($reports as $report) {\n $this->logger->info(self::LOG_PREFIX . ' Dispatching Generate Report job for report', [\n 'reportUuid' => $report->getUuid(),\n 'teamId' => $report->getTeamId(),\n 'frequency' => $report->getFrequency(),\n 'type' => $report->getType(),\n ]);\n\n $job = $report->isAskJiminnyReport()\n ? new RequestGenerateAskJiminnyReportJob($report->getUuid())\n : new RequestGenerateReportJob($report->getUuid());\n\n $this->dispatcher->dispatch($job);\n }\n }\n\n private function getReportById(string $reportId): Collection\n {\n $report = $this->reportRepository->findByIdOrUuid($reportId);\n\n if ($report === null) {\n $this->logger->warning(self::LOG_PREFIX . ' Report not found for --report-id', ['reportId' => $reportId]);\n $this->warn(\"Report not found: {$reportId}\");\n\n return collect();\n }\n\n if (! $report->getStatus()) {\n $this->logger->warning(self::LOG_PREFIX . ' Report is inactive, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n ]);\n $this->warn('Report is inactive — processing anyway (manual override).');\n }\n\n $team = $report->getTeam();\n if ($team->getStatus() !== Team::STATUS_ACTIVE) {\n $this->logger->warning(self::LOG_PREFIX . ' Team is not active, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n 'teamId' => $report->getTeamId(),\n 'teamStatus' => $team->getStatus(),\n ]);\n $this->warn(\"Team #{$report->getTeamId()} is not active — processing anyway (manual override).\");\n }\n\n if ($report->isExpired()) {\n $this->logger->warning(self::LOG_PREFIX . ' Report is expired, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n 'expiresAt' => $report->getExpiresAt()?->toDateString(),\n ]);\n $this->warn('Report is expired (expires_at: ' . $report->getExpiresAt()?->toDateString() . ') — processing anyway (manual override).');\n }\n\n $this->info(self::LOG_PREFIX . ' Automated report found ' . $report->getCustomName());\n\n return collect([$report]);\n }\n}","depth":4,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Console\\Commands\\Reports;\n\nuse Carbon\\Carbon;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Contracts\\Bus\\Dispatcher as BusDispatcher;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Jobs\\AutomatedReports\\RequestGenerateAskJiminnyReportJob;\nuse Jiminny\\Jobs\\AutomatedReports\\RequestGenerateReportJob;\nuse Jiminny\\Models\\AutomatedReport;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Repositories\\AutomatedReportsRepository;\nuse Jiminny\\Services\\Kiosk\\AutomatedReports\\AutomatedReportsService;\nuse Psr\\Log\\LoggerInterface;\n\nclass AutomatedReportsCommand extends Command\n{\n /**\n * Log prefix for all log messages\n */\n private const string LOG_PREFIX = '[automated-reports]';\n\n /**\n * The name and signature of the console command.\n *\n * @var string\n */\n protected $signature = 'automated-reports \n {--report-id= : Process a specific report by ID or UUID (bypasses frequency scheduling)}';\n\n /**\n * The console command description.\n *\n * @var string\n */\n protected $description = 'Process automated reports based on their frequency (weekly, monthly, quarterly). \n Use --report-id to manually trigger a specific report by ID or UUID.';\n\n\n public function __construct(\n private readonly LoggerInterface $logger,\n private readonly BusDispatcher $dispatcher,\n private readonly AutomatedReportsRepository $reportRepository\n ) {\n parent::__construct();\n }\n\n /**\n * Execute the console command.\n *\n * @return int\n */\n public function handle(): int\n {\n $this->logger->info(self::LOG_PREFIX . ' Started');\n\n $now = Carbon::now();\n $isMonday = $now->isMonday();\n $isFirstDayOfMonth = $now->day === 1;\n $currentMonth = $now->month;\n\n // Check if the current month is a quarterly month (January, April, July, October)\n $isQuarterlyMonth = in_array($currentMonth, [1, 4, 7, 10], true);\n\n $this->logger->info(self::LOG_PREFIX . ' Checking conditions', [\n 'isMonday' => $isMonday,\n 'isFirstDayOfMonth' => $isFirstDayOfMonth,\n 'currentMonth' => $currentMonth,\n 'isQuarterlyMonth' => $isQuarterlyMonth,\n ]);\n\n // Process daily reports\n $this->processReports(AutomatedReportsService::FREQUENCY_DAILY);\n\n // Process weekly reports on Mondays\n if ($isMonday) {\n $this->processReports(AutomatedReportsService::FREQUENCY_WEEKLY);\n }\n\n // Process monthly reports on the first day of the month\n if ($isFirstDayOfMonth) {\n $this->processReports(AutomatedReportsService::FREQUENCY_MONTHLY);\n }\n\n // Process quarterly reports on the first day of January, April, July, and October\n if ($isFirstDayOfMonth && $isQuarterlyMonth) {\n $this->processReports(AutomatedReportsService::FREQUENCY_QUARTERLY);\n }\n\n $this->logger->info(self::LOG_PREFIX . ' Completed');\n\n return 0;\n }\n\n /**\n * Process reports for a specific frequency.\n *\n * @param string $frequency\n *\n * @return void\n */\n private function processReports(string $frequency): void\n {\n $this->logger->info(self::LOG_PREFIX . \" Processing $frequency reports\");\n\n $reportId = $this->option('report-id');\n if ($reportId !== null) {\n $reports = $this->getReportById($reportId);\n } else {\n // Get all enabled, not deleted reports with active teams for the specified frequency\n $reports = $this->reportRepository->getActiveReportsByFrequency($frequency);\n }\n\n $this->logger->info(self::LOG_PREFIX . \" Found {$reports->count()} $frequency reports to process\");\n\n /** @var AutomatedReport $report */\n foreach ($reports as $report) {\n $this->logger->info(self::LOG_PREFIX . ' Dispatching Generate Report job for report', [\n 'reportUuid' => $report->getUuid(),\n 'teamId' => $report->getTeamId(),\n 'frequency' => $report->getFrequency(),\n 'type' => $report->getType(),\n ]);\n\n $job = $report->isAskJiminnyReport()\n ? new RequestGenerateAskJiminnyReportJob($report->getUuid())\n : new RequestGenerateReportJob($report->getUuid());\n\n $this->dispatcher->dispatch($job);\n }\n }\n\n private function getReportById(string $reportId): Collection\n {\n $report = $this->reportRepository->findByIdOrUuid($reportId);\n\n if ($report === null) {\n $this->logger->warning(self::LOG_PREFIX . ' Report not found for --report-id', ['reportId' => $reportId]);\n $this->warn(\"Report not found: {$reportId}\");\n\n return collect();\n }\n\n if (! $report->getStatus()) {\n $this->logger->warning(self::LOG_PREFIX . ' Report is inactive, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n ]);\n $this->warn('Report is inactive — processing anyway (manual override).');\n }\n\n $team = $report->getTeam();\n if ($team->getStatus() !== Team::STATUS_ACTIVE) {\n $this->logger->warning(self::LOG_PREFIX . ' Team is not active, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n 'teamId' => $report->getTeamId(),\n 'teamStatus' => $team->getStatus(),\n ]);\n $this->warn(\"Team #{$report->getTeamId()} is not active — processing anyway (manual override).\");\n }\n\n if ($report->isExpired()) {\n $this->logger->warning(self::LOG_PREFIX . ' Report is expired, processing anyway (manual override)', [\n 'reportId' => $reportId,\n 'reportUuid' => $report->getUuid(),\n 'expiresAt' => $report->getExpiresAt()?->toDateString(),\n ]);\n $this->warn('Report is expired (expires_at: ' . $report->getExpiresAt()?->toDateString() . ') — processing anyway (manual override).');\n }\n\n $this->info(self::LOG_PREFIX . ' Automated report found ' . $report->getCustomName());\n\n return collect([$report]);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.0140625,"top":0.041666668,"width":0.028515626,"height":0.021527778},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9074135532212230457
|
6686649039917036621
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#11894 on JY-18909-automa Project: faVsco.js, menu
#11894 on JY-18909-automated-reports-ask-jiminny, menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
16
14
13
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE id = 1;
select * from crm_layouts where crm_configuration_id = 39;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 283;
SELECT * FROM crm_fields WHERE id = 2234;
SELECT * FROM crm_field_values WHERE crm_field_id = 2234;
select * from crm_profiles where user_id = 143;
select * from record_types where crm_configuration_id = 39; # 0121K000001MHElQAO,0121K000001MHEqQAO
select * from business_processes where crm_configuration_id = 39;
# 01941000000H669AAC, 01941000000H66JAAS
select * from record_type_field_values
where record_type_id IN (24);
select * from crm_field_values where id IN (2730);
select * from crm_configurations where id = 39;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce'; #1035
select * from users where team_id = 1; # 222 group 3
SELECT * FROM activities WHERE user_id = 222 order by id desc;
select * from sidekick_settings where team_id = 1;
select * from teams where id = 1;
select * from team_features where team_id = 1;
select * from activities where crm_configuration_id = 2
and provider = 'ms-teams' and id = 608765;
SELECT * FROM activities WHERE crm_configuration_id = 2 and crm_provider_id = '59523413338';
select * from sidekick_settings where team_id = 2;
SELECT * FROM activities WHERE id = 608660;
select * from activity_summary_logs where activity_id = 608660;
select * from ai_prompts where transcription_id = 11214;
# [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('ed78a437-2804-450e-ab2f-56ab1c641346') = uuid;
# id: 608818, crm: 59628809737
SELECT * FROM activities WHERE uuid_to_bin('36b06e55-afdd-4782-8dee-c624cd0af191') = uuid;
# id: 608821, crm: 59632069252
SELECT ce.start_time, ce.end_time, a.id, a.uuid, crm_provider_id, calendar_event_id, title,
playbook_category_id, user_id, lead_id, contact_id, account_id, opportunity_id,
scheduled_start_time, scheduled_end_time, actual_start_time, actual_end_time, a.created_at
FROM activities a
join calendar_events ce on a.calendar_event_id = ce.id
WHERE a.id IN (608818, 608821);
select * from users where team_id = 1;
select * from team_settings where team_id = 1;
select * from crm_profiles where crm_configuration_id = 39 order by user_id;
select * from team_features where team_id = 1;
select * from users where team_id = 2;
SELECT * FROM activities WHERE uuid_to_bin('ec7647e9-5225-458b-b475-f31aa2769204') = uuid; # 612639
# Preslava N. Ivanova, grou id 3
SELECT * FROM opportunities WHERE uuid_to_bin('a2928fe5-aec5-46cb-85d9-7654c89e46a6') = uuid;
select * from activities where opportunity_id = 344 and actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00';
select
a.id,
a.type,
a.scheduled_start_time,
a.actual_start_time,
a.created_at,
a.opportunity_id,
a.status
FROM activities a
WHERE opportunity_id = 344
and status IN ('completed', 'received', 'delivered')
and (
(a.actual_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')
OR (a.created_at between '2024-10-11 00:00:00' and '2024-10-12 00:00:00')
OR (a.scheduled_start_time between '2024-10-11 00:00:00' and '2024-10-12 00:00:00'))
;
SELECT * FROM users WHERE id = 222;
SELECT * FROM crm_profiles WHERE user_id = 222;
select * from crm_layouts where crm_configuration_id = 39;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 281;
select * from group_deal_risk_types;
select * from opportunities where team_id = 1;
SELECT * FROM opportunities WHERE id = 315;
SELECT * FROM crm_field_data WHERE object_id = 315;
select * from crm_field_data where object_id = 260;
select * from generic_ai_prompts where subject_id = 315;
select * from teams; # 36, 21, 121, [EMAIL]
SELECT * FROM social_accounts WHERE sociable_id = 121 and provider = 'bullhorn';
# [PASSWORD_DOTS]
select * from teams where id = 1;
select * from crm_configurations where id = 39;
select * from users where team_id = 1;
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 1;
# 1 - 00541000004281rAAA
# 204 - 0052g000003freeAAA
# 429 - 0052g000003qGOiAAM
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce';
select * from activities where type = 'softphone'
and created_at > '2024-12-11 15:24:36' order by id desc;
select * from activity_providers where team_id = 1;
select * from activity_provider_users where activity_provider_id = 328;
select * from opportunities where crm_configuration_id = 39
AND account_id = 178 AND is_closed = false
order by created_at DESC;
select * from contacts where id = 3952;
select * from accounts where id = 178;
# [PASSWORD_DOTS]
select * from teams where id = 36;
select * from crm_configurations where id = 21;
select * from users where team_id = 36;
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 36;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 36
and sa.provider = 'bullhorn';
select * from social_accounts where id = 348;
UPDATE social_accounts SET
provider_user_token = '21442_6802599_91:41179a58-21e7-4d7c-ad58-56bb666b2f65',
provider_refresh_token = '21442_6802599_91:01c6b335-3f2a-42e4-85ff-8a08fa65fceb',
expires = 1733998131,
state = 'connected'
WHERE id = 348;
# [PASSWORD_DOTS]
select * from teams where id = 31;
select * from crm_configurations where id = 18;
select * from users where team_id = 31; # 257
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 31;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 31
and sa.provider = 'close';
select * from contacts where crm_configuration_id = 18;
# [PASSWORD_DOTS] NEPTUNE [PASSWORD_DOTS]
select * from teams;
select * from users where id IN (1030, 1035, 1052);
select * from crm_configurations;
select * from users where team_id = 65; # 257
select * from team_settings where team_id = 65; # 257
select * from invitations where team_id = 65; # 257
select * from users where email = '[EMAIL]'; # 257
select u.email, cp.* from users u
join crm_profiles cp on u.id = cp.user_id
where u.team_id = 65;
select * from crm_configurations where id = 53;
select * from accounts where crm_configuration_id = 53 order by id desc;
select * from leads where crm_configuration_id = 53 order by id desc;
select * from contacts where crm_configuration_id = 53 order by id desc;
select * from opportunities where crm_configuration_id = 53 order by id desc;
select * from crm_profiles where crm_configuration_id = 53 order by id desc;
select * from crm_fields where crm_configuration_id = 53 order by id desc;
select * from crm_field_values where crm_field_id = 3341 order by id desc;
select * from crm_layouts where crm_configuration_id = 53 order by id desc;
select * from stages where crm_configuration_id = 53 order by id desc;
select * from crm_profiles where crm_configuration_id = 13;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 65
and sa.provider = 'integration-app';
select * from contacts where crm_configuration_id = 13;
select * from social_accounts where sociable_id = 283;
SELECT * FROM opportunities WHERE crm_provider_id = '006O400000E9bzeIAB';
select * from activity_providers where team_id = 65;
SELECT * FROM activities WHERE crm_configuration_id IN (51, 52, 53);
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 65
;
# [PASSWORD_DOTS] STAGING [PASSWORD_DOTS]
SELECT * FROM teams;
SELECT * FROM teams WHERE id = 88;
SELECT * FROM teams WHERE id = 89;
select * from team_settings where team_id = 89;
SELECT * FROM users WHERE team_id = 89;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 89;
select * from users;
SELECT * FROM social_accounts WHERE sociable_id = 1761;
SELECT * FROM crm_configurations WHERE id = 70;
select * from accounts where crm_configuration_id = 70 order by id desc;
select * from leads where crm_configuration_id = 70 order by id desc;
select * from contacts where crm_configuration_id = 70 order by id desc;
select * from opportunities where crm_configuration_id = 70 order by id desc;
select * from crm_profiles where crm_configuration_id = 70 order by id desc;
select * from crm_fields where crm_configuration_id = 70 order by id desc;
select * from crm_field_values where crm_field_id = 3536 order by id desc;
select * from crm_layouts where crm_configuration_id = 70 order by id desc;
select * from stages where crm_configuration_id = 70 order by id desc;
select * from business_processes where crm_configuration_id = 70 order by id desc;
select * from business_process_stages where business_process_id = 34;
select * from contacts where id = 10468;
select * from crm_layouts where crm_configuration_id = 70;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 388;
SELECT * FROM crm_fields WHERE id IN (3533,3534,3535);
select * from activities where crm_configuration_id = 70
and (account_id IS NOT NULL or lead_id IS NOT NULL or contact_id IS NOT NULL or opportunity_id IS NOT NULL) order by id desc;
SELECT * FROM activities WHERE uuid_to_bin('2e10b60f-8a61-41c5-a3d4-28835353dc65') = uuid;
SELECT * FROM activities where crm_configuration_id = 69 ;
SELECT * FROM users WHERE email LIKE '%[EMAIL]%';
SELECT * FROM activities WHERE uuid_to_bin('5a150c93-40fc-42ec-b3bd-c1d328e09f6e') = uuid;
SELECT * FROM opportunities WHERE id = 385;
select * from participants p
join activities a on p.activity_id = a.id
where a.crm_configuration_id = 70
and (p.lead_id IS NOT NULL or p.contact_id IS NOT NULL);
SELECT * FROM participants WHERE id = 1013638;
select * from teams where id = 90;
select * from users where team_id = 90;
select * from social_accounts where social_accounts.sociable_id IN (1960,1760);
SELECT * FROM crm_profiles WHERE crm_configuration_id = 71;
select * from invitations where team_id = 90;
select * from crm_configurations where id = 71;
select * from accounts where crm_configuration_id = 71 order by id desc;
select * from leads where crm_configuration_id = 71 order by id desc;
select * from contacts where crm_configuration_id = 71 order by id desc;
select * from opportunities where crm_configuration_id = 71 order by id desc;
select * from crm_profiles where crm_configuration_id = 71 order by id desc;
select * from crm_fields where crm_configuration_id = 71 order by id desc;
select * from crm_field_values where crm_field_id = 3341 order by id desc;
select * from crm_layouts where crm_configuration_id = 71 order by id desc;
select * from stages where crm_configuration_id = 71 order by id desc;
select * from users order by secondary_email desc;
select u.id, u.email, u.status, sa.id, sa.provider_user_id from social_accounts sa
join users u on sa.sociable_id = u.id
where sa.provider = 'google' and u.email LIKE 'aneliya%';
select * from failed_jobs order by id desc;
select * from users where email = '[EMAIL]' or secondary_email = '[EMAIL]';
select * from teams;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 39;
SELECT * FROM crm_fields WHERE crm_configuration_id = 39 and object_type = 'task';
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 1
and sa.provider = 'salesforce';
# [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('c38b3895-fd0f-4b1f-9fb2-c170dba137c6') = uuid;
SELECT * FROM crm_configurations WHERE id = 70;
select * from teams where id = 1;
select * from groups where team_id = 1;
select * from users where team_id = 1;
select o.id, o.name,o.close_date, u.id, u.name, u.group_id, r.id, r.display_name, g.name, g.scope from opportunities o
join users u on o.user_id = u.id
join groups g on u.group_id = g.id
join role_user ru on u.id = ru.user_id
join roles r on ru.role_id = r.id
where o.crm_configuration_id = 39 and close_date > '2024-01-01 00:00:00';
select * from role_user where user_id = 143;
select * from roles;
select * from role_user;
select * from groups where id = 9;
select * from scope_groups where group_id = 9;
# [PASSWORD_DOTS]
select * from teams where id = 36;
select * from crm_configurations;
SELECT * FROM social_accounts WHERE sociable_id = 121;
[URL_WITH_CREDENTIALS] string
*/
protected $signature = 'automated-reports
{--report-id= : Process a specific report by ID or UUID (bypasses frequency scheduling)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process automated reports based on their frequency (weekly, monthly, quarterly).
Use --report-id to manually trigger a specific report by ID or UUID.';
public function __construct(
private readonly LoggerInterface $logger,
private readonly BusDispatcher $dispatcher,
private readonly AutomatedReportsRepository $reportRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->logger->info(self::LOG_PREFIX . ' Started');
$now = Carbon::now();
$isMonday = $now->isMonday();
$isFirstDayOfMonth = $now->day === 1;
$currentMonth = $now->month;
// Check if the current month is a quarterly month (January, April, July, October)
$isQuarterlyMonth = in_array($currentMonth, [1, 4, 7, 10], true);
$this->logger->info(self::LOG_PREFIX . ' Checking conditions', [
'isMonday' => $isMonday,
'isFirstDayOfMonth' => $isFirstDayOfMonth,
'currentMonth' => $currentMonth,
'isQuarterlyMonth' => $isQuarterlyMonth,
]);
// Process daily reports
$this->processReports(AutomatedReportsService::FREQUENCY_DAILY);
// Process weekly reports on Mondays
if ($isMonday) {
$this->processReports(AutomatedReportsService::FREQUENCY_WEEKLY);
}
// Process monthly reports on the first day of the month
if ($isFirstDayOfMonth) {
$this->processReports(AutomatedReportsService::FREQUENCY_MONTHLY);
}
// Process quarterly reports on the first day of January, April, July, and October
if ($isFirstDayOfMonth && $isQuarterlyMonth) {
$this->processReports(AutomatedReportsService::FREQUENCY_QUARTERLY);
}
$this->logger->info(self::LOG_PREFIX . ' Completed');
return 0;
}
/**
* Process reports for a specific frequency.
*
* @param string $frequency
*
* @return void
*/
private function processReports(string $frequency): void
{
$this->logger->info(self::LOG_PREFIX . " Processing $frequency reports");
$reportId = $this->option('report-id');
if ($reportId !== null) {
$reports = $this->getReportById($reportId);
} else {
// Get all enabled, not deleted reports with active teams for the specified frequency
$reports = $this->reportRepository->getActiveReportsByFrequency($frequency);
}
$this->logger->info(self::LOG_PREFIX . " Found {$reports->count()} $frequency reports to process");
/** @var AutomatedReport $report */
foreach ($reports as $report) {
$this->logger->info(self::LOG_PREFIX . ' Dispatching Generate Report job for report', [
'reportUuid' => $report->getUuid(),
'teamId' => $report->getTeamId(),
'frequency' => $report->getFrequency(),
'type' => $report->getType(),
]);
$job = $report->isAskJiminnyReport()
? new RequestGenerateAskJiminnyReportJob($report->getUuid())
: new RequestGenerateReportJob($report->getUuid());
$this->dispatcher->dispatch($job);
}
}
private function getReportById(string $reportId): Collection
{
$report = $this->reportRepository->findByIdOrUuid($reportId);
if ($report === null) {
$this->logger->warning(self::LOG_PREFIX . ' Report not found for --report-id', ['reportId' => $reportId]);
$this->warn("Report not found: {$reportId}");
return collect();
}
if (! $report->getStatus()) {
$this->logger->warning(self::LOG_PREFIX . ' Report is inactive, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
]);
$this->warn('Report is inactive — processing anyway (manual override).');
}
$team = $report->getTeam();
if ($team->getStatus() !== Team::STATUS_ACTIVE) {
$this->logger->warning(self::LOG_PREFIX . ' Team is not active, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
'teamId' => $report->getTeamId(),
'teamStatus' => $team->getStatus(),
]);
$this->warn("Team #{$report->getTeamId()} is not active — processing anyway (manual override).");
}
if ($report->isExpired()) {
$this->logger->warning(self::LOG_PREFIX . ' Report is expired, processing anyway (manual override)', [
'reportId' => $reportId,
'reportUuid' => $report->getUuid(),
'expiresAt' => $report->getExpiresAt()?->toDateString(),
]);
$this->warn('Report is expired (expires_at: ' . $report->getExpiresAt()?->toDateString() . ') — processing anyway (manual override).');
}
$this->info(self::LOG_PREFIX . ' Automated report found ' . $report->getCustomName());
return collect([$report]);
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
|
7640
|
143
|
23
|
2026-04-13T16:15:02.027297+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-13/1776 /Users/lukas/.screenpipe/data/data/2026-04-13/1776096902027_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelplallA100% <47-zshMon 13 Apr 19:15:01T81DOCKER• 881DEV (-zsh)О ₴2APP (-zsh)• *3-zsh• ×4-zsh• ₴5|-zsh886-zshO &7* Unable to acce... →,- 88{"app_name":"QuickTimePlayer""n":16},{"app_name" : "UserNotificationCenter", "n":4},{"app_name"::"Activity Monitor"{"app_name" : "NetAuthAgent","n":4},,"n":3},{"app_name": "Control Centre", "n" :3},{"app_name" : "Slack","n" :2},{"app_name": "Raycast","n":2},{"app_name": "System Settings","n" :1},{"app_name" : "Preview"."n":1}]lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $sqlite3 -column -header ~/.screenpipe/db.sqlite "SELECT timestamp, app_name, window_name FROM frames ORDER BY timestamp DESC LIMIT10;"timestampapp_namewindow_name2026-04-12113:33:14.649009+00:00iTerm22026-04-12T13:33:13.963922+00:002026-04-12T13:33:13.415261+00:002026-04-12T13:33:12.521412+00:002026-04-12T13:33:09.128387+00:002026-04-12T13:33:06.137541+00:002026-04-12T13:33:05.165254+00:00iTerm2iTerm2iTerm2Claude2026-04-12113:32:50.425455+00:002026-04-12T13:32:49.108195+00:00Claude2026-04-12T13:32:45.339017+00:00iTerm2lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny-zsh-zsh-zsh-zsh-zsh"status": "healthy""frame_status": "ok""audio_status": "disabled","last_frame": "2026-04-13T15:11:31+03:00","uptime": 19271.640087958,"fps": 0.11841234008027791,"frames": 2282}4.0K256M418M64K196K24K132K132K132K32Klukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ S du-sh ~/.screenpipe/*/Users/lukas/.screenpipe/config.json/Users/lukas/.screenpipe/data/Users/lukas/.screenpipe/db.sqlite/Users/lukas/.screenpipe/db.sqlite-shm/Users/lukas/.screenpipe/db.sqlite-wal/Users/lukas/.screenpipe/pipes/Users/Lukas/.screenpipe/screenpipe.2026-04-09.0.10g/Users/lukas/.screenpipe/screenpipe.2026-04-11.0.log/Users/lukas/.screenpipe/screenpipe.2026-04-12.0.1og/Users/lukas/.screenpipe/screenpipe.2026-04-13.0.loglukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ S du -sh ~/.screenpipe...
|
NULL
|
-9073938714244277061
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelplallA100% <47-zshMon 13 Apr 19:15:01T81DOCKER• 881DEV (-zsh)О ₴2APP (-zsh)• *3-zsh• ×4-zsh• ₴5|-zsh886-zshO &7* Unable to acce... →,- 88{"app_name":"QuickTimePlayer""n":16},{"app_name" : "UserNotificationCenter", "n":4},{"app_name"::"Activity Monitor"{"app_name" : "NetAuthAgent","n":4},,"n":3},{"app_name": "Control Centre", "n" :3},{"app_name" : "Slack","n" :2},{"app_name": "Raycast","n":2},{"app_name": "System Settings","n" :1},{"app_name" : "Preview"."n":1}]lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $sqlite3 -column -header ~/.screenpipe/db.sqlite "SELECT timestamp, app_name, window_name FROM frames ORDER BY timestamp DESC LIMIT10;"timestampapp_namewindow_name2026-04-12113:33:14.649009+00:00iTerm22026-04-12T13:33:13.963922+00:002026-04-12T13:33:13.415261+00:002026-04-12T13:33:12.521412+00:002026-04-12T13:33:09.128387+00:002026-04-12T13:33:06.137541+00:002026-04-12T13:33:05.165254+00:00iTerm2iTerm2iTerm2Claude2026-04-12113:32:50.425455+00:002026-04-12T13:32:49.108195+00:00Claude2026-04-12T13:32:45.339017+00:00iTerm2lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny-zsh-zsh-zsh-zsh-zsh"status": "healthy""frame_status": "ok""audio_status": "disabled","last_frame": "2026-04-13T15:11:31+03:00","uptime": 19271.640087958,"fps": 0.11841234008027791,"frames": 2282}4.0K256M418M64K196K24K132K132K132K32Klukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ S du-sh ~/.screenpipe/*/Users/lukas/.screenpipe/config.json/Users/lukas/.screenpipe/data/Users/lukas/.screenpipe/db.sqlite/Users/lukas/.screenpipe/db.sqlite-shm/Users/lukas/.screenpipe/db.sqlite-wal/Users/lukas/.screenpipe/pipes/Users/Lukas/.screenpipe/screenpipe.2026-04-09.0.10g/Users/lukas/.screenpipe/screenpipe.2026-04-11.0.log/Users/lukas/.screenpipe/screenpipe.2026-04-12.0.1og/Users/lukas/.screenpipe/screenpipe.2026-04-13.0.loglukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ S du -sh ~/.screenpipe...
|
7639
|
|
1998
|
39
|
78
|
2026-04-12T08:45:59.064765+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-12/1775 /Users/lukas/.screenpipe/data/data/2026-04-12/1775983559064_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2ShellEdit|ViewSessionScriptsProfilesWindowHe iTerm2ShellEdit|ViewSessionScriptsProfilesWindowHelpI46|A100% C8Sun 12 Apr 11:45:58DOCKERSonnet4.6OrganizationO ₴1DEV (-zsh)О 82ClaudePro. [EMAIL]'s/Users/lukasAPP (-zsh)• *3* Unable to access screenpipe activity data-zsh• ₴4-zsh• *5-zsh• *6-zsh$7* Unable to access s...T888what did I do yesterday? Give me summary of my activity from screenpipe• screenpipe - Activity Summary (MCP)(start_time: "2026-04-11T00:00:00", end_time: "2026-04-11T23:59:59")• screenpipe - Activity Summary (MCP) (start_time: "2026-04-11T00:00:00Z", end_time:"2026-04-11T23:59:59Z")• Here's your activity summary for yesterday (April 11):Total active time: ~3 hours (roughly 15:00-21:00 UTC)Main focus areasScreenpipe development (~2h combined)- Heavy terminalwork in iTerm2 (67 min in zsh + SSH)- Building a screenpipe data UI - working remotely on [IP_ADDRESS] via SSH- Editing report.py and a bash script in VS Code- Checking the Screenpipe Dashboard in FirefoxWork (Jiminny) (~15 min)- Checked the Scrum Board for Platform Sprint 1 Q2- Looked at service desk queues- Checked an email about an "Ask Jiminny" test report- Briefly attempted to access app.dev.jiminny.com (got a page load error)- Explored Gemini for Google Workspace (request access)NAS / personal infra (~12 min)- Browsing your NAS at nas.lakylak.xyzReading (Denník N) (~6 min)- Article about the Artemis II / Orion splashdown- Coverage on Iran and Trump newsClaude (~5 min)- Used Claude, likely related to screenpipe work (querying the screenpipe search API)TL;DR: Mostly a dev day focused on building something with screenpipe data (a reporting UI), some light work task-checking on Jira/email, and evening reading on Slovak news....
|
NULL
|
-9073742802087065363
|
NULL
|
visual_change
|
ocr
|
NULL
|
iTerm2ShellEdit|ViewSessionScriptsProfilesWindowHe iTerm2ShellEdit|ViewSessionScriptsProfilesWindowHelpI46|A100% C8Sun 12 Apr 11:45:58DOCKERSonnet4.6OrganizationO ₴1DEV (-zsh)О 82ClaudePro. [EMAIL]'s/Users/lukasAPP (-zsh)• *3* Unable to access screenpipe activity data-zsh• ₴4-zsh• *5-zsh• *6-zsh$7* Unable to access s...T888what did I do yesterday? Give me summary of my activity from screenpipe• screenpipe - Activity Summary (MCP)(start_time: "2026-04-11T00:00:00", end_time: "2026-04-11T23:59:59")• screenpipe - Activity Summary (MCP) (start_time: "2026-04-11T00:00:00Z", end_time:"2026-04-11T23:59:59Z")• Here's your activity summary for yesterday (April 11):Total active time: ~3 hours (roughly 15:00-21:00 UTC)Main focus areasScreenpipe development (~2h combined)- Heavy terminalwork in iTerm2 (67 min in zsh + SSH)- Building a screenpipe data UI - working remotely on [IP_ADDRESS] via SSH- Editing report.py and a bash script in VS Code- Checking the Screenpipe Dashboard in FirefoxWork (Jiminny) (~15 min)- Checked the Scrum Board for Platform Sprint 1 Q2- Looked at service desk queues- Checked an email about an "Ask Jiminny" test report- Briefly attempted to access app.dev.jiminny.com (got a page load error)- Explored Gemini for Google Workspace (request access)NAS / personal infra (~12 min)- Browsing your NAS at nas.lakylak.xyzReading (Denník N) (~6 min)- Article about the Artemis II / Orion splashdown- Coverage on Iran and Trump newsClaude (~5 min)- Used Claude, likely related to screenpipe work (querying the screenpipe search API)TL;DR: Mostly a dev day focused on building something with screenpipe data (a reporting UI), some light work task-checking on Jira/email, and evening reading on Slovak news....
|
NULL
|
|
76452
|
1917
|
0
|
2026-04-24T07:59:11.454248+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-24/1777 /Users/lukas/.screenpipe/data/data/2026-04-24/1777017551454_m1.jpg...
|
Firefox
|
JY-9712 | Nuges to expire after one year by nikola JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app — Work...
|
True
|
github.com/jiminny/app/pull/11981
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Platform Sprint 2 Q2 - Platform Team - Scrum Board Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
JY-20489 | Optimize Nudges - Phase 2 by yalokin-jiminny · Pull Request #11997 · jiminny/app
JY-20489 | Optimize Nudges - Phase 2 by yalokin-jiminny · Pull Request #11997 · jiminny/app
New Tab
New Tab
AI reports promotion pages by nikolay-yankov · Pull Request #11998 · jiminny/app
AI reports promotion pages by nikolay-yankov · Pull Request #11998 · jiminny/app
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
Close tab
Jiminny
Jiminny
Userpilot | Nudge-created
Userpilot | Nudge-created
Summary - app in Jiminny SonarQube Cloud
Summary - app in Jiminny SonarQube Cloud
Pipelines - jiminny/app
Pipelines - jiminny/app
New Tab
New Tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
Issues(g then i)
Pull requests
Repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (31)
Pull requests
(
31
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (32)
Security and quality
(
32
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
Review requested
Review requested
nikolaybiaivanov
nikolaybiaivanov
requested your review on this pull request.
Add your review
Add your review
JY-9712 | Nuges to expire after one year #11981 Edit title
JY-9712 | Nuges to expire after one year
#
11981
Edit title
Unable to merge
Unable to merge
Code
Code
Open
nikolaybiaivanov
nikolaybiaivanov
wants to merge 14 commits into
master
master
from
JY-9712-change-forever-nudges-to-1-year-expiration
JY-9712-change-forever-nudges-to-1-year-expiration
Copy head branch name to clipboard
Lines changed: 1276 additions & 1668 deletions
Conversation (11)
Conversation
(
11
)
Commits (14)
Commits
(
14
)
Checks (3)
Checks
(
3
)
Files changed (33)
Files changed
(
33
)
Conversation
Conversation
@nikolaybiaivanov
Show options
nikolaybiaivanov commented last week
nikolaybiaivanov
nikolaybiaivanov
commented
last week
last week
JIRA: JY-9712
JIRA:
JY-9712
JY-9712
Deployment notes:
Deployment notes:
None
Add or remove reactions
@nikolaybiaivanov
Show options
Author
nikolaybiaivanov commented last week
nikolaybiaivanov
nikolaybiaivanov
commented
last week
last week
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented last week •
claude
claude
Bot
commented
last week
last week
•
edited
edited
Claude finished
@nikolaybiaivanov
@nikolaybiaivanov
's task in 2m 9s
——
View job
View job
Code Review
Code Review
Completed task
Gather context and read changed files
Completed task
Review NudgeFactory.php changes
Completed task
Review NudgeRepository.php changes
Completed task
Review RemoveExpiredNudgesCommand.php changes
Completed task
Review SendNudgeExpirationWarningsCommand.php (new)
Completed task
Review NudgeController.php changes
Completed task
Review Nudge.php model changes
Completed task
Review NudgeExpirationWarningNotification.php (new)
Completed task
Review migration file (new)
Completed task
Review email templates (new)
Completed task
Review Kernel.php changes
Completed task
Post final review
Overall this is a well-structured refactor. The removal of the legacy
expires_in
integer enum in favour of a real datetime
expires_at
, the migration for existing data, and the warning-email pipeline all fit together cleanly. A few things worth looking at:
1. Migration: per-row UPDATE is slow at scale
1. Migration: per-row UPDATE is slow at scale
database/migrations/2026_04_16_121204_migrate_existing_nudges_expiration_data.php
The migration issues one
UPDATE
per nudge inside a chunk loop. For large tables this will be very slow. You can replace it with two bulk updates and drop the PHP loop entirely:
--
Nudges older than 1 year → expire in 8 days
UPDATE
nudges
SET
expires_at
=
DATE_ADD(NOW(), INTERVAL
8
DAY)
WHERE
expires_at IS
NULL
AND
created_at
<
DATE_SUB(NOW(), INTERVAL
1
YEAR);
--
Newer nudges → expire 1 year after creation
UPDATE
nudges
SET
expires_at
=
DATE_ADD(created_at, INTERVAL
1
YEAR)
WHERE
expires_at IS
NULL
AND
created_at
>=
DATE_SUB(NOW(), INTERVAL
1
YEAR);
Copy
The
endOfDay()
adjustment is a nice touch that should be preserved if you keep the chunk approach; raw SQL would lose that. But even a chunk-based approach can batch the updates with a single
whereIn
on IDs per chunk rather than one query per row.
Fix this →
Fix this →
2. NudgeExpirationWarningNotification — side-effect in via()
2.
NudgeExpirationWarningNotification
— side-effect in
via()
app/Notifications/NudgeExpirationWarningNotification.php:15–29
$formattedExpiresAt
is set in
via()
and consumed in
toPostmark()
. This creates an implicit ordering dependency. Laravel does call
via()
first, but if this notification is ever queued or serialised the state won't survive. Move the formatting logic into
toPostmark()
(or a private helper called from there) to make it self-contained:
public
function
toPostmark
():
EmailWithTemplate
{
$
email
=
new
EmailWithTemplate
(
'
nudge-expiration-warning
'
);
$
search
=
$
this
->
nudge
->
getActivitySearch
();
$
expiresAt
=
$
this
->
nudge
->
getExpiresAt
();
$
email
->
setData
([
'
nudge_name
'
=>
$
search
->
getName
(),
'
expires_at
'
=>
$
expiresAt
->
format
(
'
F j, Y
'
),
// timezone formatting can be passed separately
'
search_url
'
=>
route
(
'
show.ondemand.page
'
),
]);
return
$
email
;
}
Copy
Fix this →
Fix this →
3. squashNudgeRuns semantics change
3.
squashNudgeRuns
semantics change
app/Console/Commands/RemoveExpiredNudgesCommand.php:95
The condition changed from
whereNull('expires_at')
(only "never-expiring" nudges) to
where('expires_at', '>', now())
(all active nudges). Since nudges no longer have a "never expire" option this makes sense, but it's worth confirming: previously only nudges that had
expires_in = EXPIRE_NEVER
were squashed, now
all active nudges
are candidates. If that's intentional, a short comment explaining the rationale would help future readers.
4. SendNudgeExpirationWarningsCommand — missing Sentry capture
4.
SendNudgeExpirationWarningsCommand
— missing Sentry capture
app/Console/Commands/SendNudgeExpirationWarningsCommand.php:56–64
RemoveExpiredNudgesCommand
reports exceptions to Sentry;
SendNudgeExpirationWarningsCommand
only logs them. If sending a warning fails silently in production you won't get an alert. Consider adding
Sentry::captureException($e)
in the
catch
block, consistent with the sibling command.
Fix this →
Fix this →
5. Minor — Command::SUCCESS constant
5. Minor —
Command::SUCCESS
constant
app/Console/Commands/SendNudgeExpirationWarningsCommand.php:79
return
0
;
Copy
Should be
return Command::SUCCESS;
for readability and to match Laravel convention.
6. NudgeRepository — expires_at IS NULL edge case
6.
NudgeRepository
—
expires_at IS NULL
edge case
app/Component/Nudge/Repository/NudgeRepository.php:123
->
where
(
'
expires_at
'
,
'
>
'
,
now
())
Copy
This silently excludes any nudge with a
NULL
expires_at
(which should be zero after the migration runs, but worth noting). If the migration ever fails mid-run, affected nudges would stop triggering until the migration is re-run. Adding a note in the migration's docblock about this dependency would help with incident response.
7. Email template — link to specific section
7. Email template — link to specific section
resources/views/emails/postmark-templates/email/nudge-expiration-warning.email.mustache
The email instructs users to go to "Jiminny → On Demand → Saved Searches" but
search_url
points to the generic On Demand page. If a direct URL to the Saved Searches tab is available, using it would reduce friction for the user.
Summary
Summary
Severity
Issue
Medium
Migration does N+1 UPDATEs — use bulk queries
Medium
via()...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"JY-20489 | Optimize Nudges - Phase 2 by yalokin-jiminny · Pull Request #11997 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20489 | Optimize Nudges - Phase 2 by yalokin-jiminny · Pull Request #11997 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"AI reports promotion pages by nikolay-yankov · Pull Request #11998 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"AI reports promotion pages by nikolay-yankov · Pull Request #11998 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Jiminny","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Userpilot | Nudge-created","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Userpilot | Nudge-created","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Summary - app in Jiminny SonarQube Cloud","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Summary - app in Jiminny SonarQube Cloud","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"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.0,"top":0.0,"width":0.022222223,"height":0.035555556},"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.0,"top":0.0,"width":0.022222223,"height":0.035555556},"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.0,"top":0.0,"width":0.022222223,"height":0.035555556},"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.0,"top":0.0,"width":0.022222223,"height":0.035555556},"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.016666668,"top":0.0,"width":0.022222223,"height":0.035555556},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Issues(g then i)","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Pull requests","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Repositories","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (31)","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"31","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (32)","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"32","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Important update","depth":10,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Important update","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Review this update","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review this update","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and manage your preferences in your","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub account settings","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub account settings","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss banner","depth":9,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Review requested","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Review requested","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"nikolaybiaivanov","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"nikolaybiaivanov","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"requested your review on this pull request.","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Add your review","depth":14,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Add your review","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JY-9712 | Nuges to expire after one year #11981 Edit title","depth":13,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JY-9712 | Nuges to expire after one year","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"11981","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit title","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Unable to merge","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Unable to merge","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Code","depth":13,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Code","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"nikolaybiaivanov","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"nikolaybiaivanov","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 14 commits into","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":15,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-9712-change-forever-nudges-to-1-year-expiration","depth":16,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-9712-change-forever-nudges-to-1-year-expiration","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Lines changed: 1276 additions & 1668 deletions","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Conversation (11)","depth":16,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Conversation","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"11","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Commits (14)","depth":16,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Commits","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"14","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Checks (3)","depth":16,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Files changed (33)","depth":16,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Files changed","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"33","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Conversation","depth":12,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@nikolaybiaivanov","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"nikolaybiaivanov commented last week","depth":14,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"nikolaybiaivanov","depth":16,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"nikolaybiaivanov","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"last week","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"last week","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"JIRA: JY-9712","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"JIRA:","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"JY-9712","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-9712","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Deployment notes:","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Deployment notes:","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"None","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":16,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"@nikolaybiaivanov","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Author","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"nikolaybiaivanov commented last week","depth":13,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"nikolaybiaivanov","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"nikolaybiaivanov","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"last week","depth":14,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"last week","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@claude","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add or remove reactions","depth":15,"help_text":"","role_description":"summary","subrole":"AXSummary","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"react with eyes","depth":14,"role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"👀","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@claude","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"claude Bot commented last week •","depth":13,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"claude","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"claude","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bot","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"last week","depth":14,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"last week","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"•","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"edited","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"edited","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Claude finished","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@nikolaybiaivanov","depth":18,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"@nikolaybiaivanov","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'s task in 2m 9s","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"——","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"View job","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"View job","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Code Review","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Code Review","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Gather context and read changed files","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review NudgeFactory.php changes","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review NudgeRepository.php changes","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review RemoveExpiredNudgesCommand.php changes","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review SendNudgeExpirationWarningsCommand.php (new)","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.4375,"top":0.0,"width":0.009722223,"height":0.015555556},"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review NudgeController.php changes","depth":18,"bounds":{"left":0.44930556,"top":0.0,"width":0.17152777,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.4375,"top":0.0,"width":0.009722223,"height":0.015555556},"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review Nudge.php model changes","depth":18,"bounds":{"left":0.44930556,"top":0.0,"width":0.15833333,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.4375,"top":0.0,"width":0.009722223,"height":0.015555556},"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review NudgeExpirationWarningNotification.php (new)","depth":18,"bounds":{"left":0.44930556,"top":0.0,"width":0.2482639,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.4375,"top":0.0,"width":0.009722223,"height":0.015555556},"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review migration file (new)","depth":18,"bounds":{"left":0.44930556,"top":0.0,"width":0.124305554,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.4375,"top":0.015555556,"width":0.009722223,"height":0.015555556},"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review email templates (new)","depth":18,"bounds":{"left":0.44930556,"top":0.013888889,"width":0.13645834,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.4375,"top":0.043333333,"width":0.009722223,"height":0.015555556},"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review Kernel.php changes","depth":18,"bounds":{"left":0.44930556,"top":0.041666668,"width":0.12638889,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Completed task","depth":18,"bounds":{"left":0.4375,"top":0.071666665,"width":0.009722223,"height":0.015555556},"help_text":"","role_description":"checkbox","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Post final review","depth":18,"bounds":{"left":0.44930556,"top":0.06944445,"width":0.07604167,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Overall this is a well-structured refactor. The removal of the legacy","depth":17,"bounds":{"left":0.43263888,"top":0.15277778,"width":0.30173612,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_in","depth":18,"bounds":{"left":0.7375,"top":0.15555556,"width":0.05,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"integer enum in favour of a real datetime","depth":17,"bounds":{"left":0.43263888,"top":0.15277778,"width":0.50069445,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at","depth":18,"bounds":{"left":0.478125,"top":0.17888889,"width":0.049652778,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", the migration for existing data, and the warning-email pipeline all fit together cleanly. A few things worth looking at:","depth":17,"bounds":{"left":0.43263888,"top":0.17611112,"width":0.5121528,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"1. Migration: per-row UPDATE is slow at scale","depth":16,"bounds":{"left":0.43263888,"top":0.2777778,"width":0.5375,"height":0.02388889},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1. Migration: per-row UPDATE is slow at scale","depth":17,"bounds":{"left":0.43263888,"top":0.2777778,"width":0.2576389,"height":0.023333333},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"database/migrations/2026_04_16_121204_migrate_existing_nudges_expiration_data.php","depth":18,"bounds":{"left":0.4357639,"top":0.32444444,"width":0.403125,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The migration issues one","depth":17,"bounds":{"left":0.43263888,"top":0.36277777,"width":0.114930555,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"UPDATE","depth":18,"bounds":{"left":0.55104166,"top":0.36555555,"width":0.029861111,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"per nudge inside a chunk loop. For large tables this will be very slow. You can replace it with two bulk updates and drop the PHP loop entirely:","depth":17,"bounds":{"left":0.43263888,"top":0.36277777,"width":0.5361111,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"--","depth":17,"bounds":{"left":0.44375,"top":0.44444445,"width":0.009722223,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Nudges older than 1 year → expire in 8 days","depth":17,"bounds":{"left":0.45347223,"top":0.44444445,"width":0.21909723,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"UPDATE","depth":17,"bounds":{"left":0.44375,"top":0.46388888,"width":0.029861111,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"nudges","depth":17,"bounds":{"left":0.47361112,"top":0.46388888,"width":0.034722224,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SET","depth":17,"bounds":{"left":0.44375,"top":0.48277777,"width":0.014930556,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at","depth":17,"bounds":{"left":0.45868057,"top":0.48277777,"width":0.059722222,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"bounds":{"left":0.51840276,"top":0.48277777,"width":0.0048611113,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DATE_ADD(NOW(), INTERVAL","depth":17,"bounds":{"left":0.5232639,"top":0.48277777,"width":0.12951389,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"8","depth":17,"bounds":{"left":0.6527778,"top":0.48277777,"width":0.0048611113,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DAY)","depth":17,"bounds":{"left":0.6576389,"top":0.48277777,"width":0.025,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"WHERE","depth":17,"bounds":{"left":0.44375,"top":0.50222224,"width":0.024652777,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at IS","depth":17,"bounds":{"left":0.46840277,"top":0.50222224,"width":0.074652776,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"NULL","depth":17,"bounds":{"left":0.54305553,"top":0.50222224,"width":0.02013889,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"AND","depth":17,"bounds":{"left":0.45347223,"top":0.52111113,"width":0.014930556,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"created_at","depth":17,"bounds":{"left":0.46840277,"top":0.52111113,"width":0.059722222,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"<","depth":17,"bounds":{"left":0.528125,"top":0.52111113,"width":0.0052083335,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DATE_SUB(NOW(), INTERVAL","depth":17,"bounds":{"left":0.53333336,"top":0.52111113,"width":0.12916666,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":17,"bounds":{"left":0.6625,"top":0.52111113,"width":0.0052083335,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"YEAR);","depth":17,"bounds":{"left":0.66770834,"top":0.52111113,"width":0.034722224,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"--","depth":17,"bounds":{"left":0.44375,"top":0.5594444,"width":0.009722223,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Newer nudges → expire 1 year after creation","depth":17,"bounds":{"left":0.45347223,"top":0.5594444,"width":0.21909723,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"UPDATE","depth":17,"bounds":{"left":0.44375,"top":0.5788889,"width":0.029861111,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"nudges","depth":17,"bounds":{"left":0.47361112,"top":0.5788889,"width":0.034722224,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SET","depth":17,"bounds":{"left":0.44375,"top":0.5977778,"width":0.014930556,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at","depth":17,"bounds":{"left":0.45868057,"top":0.5977778,"width":0.059722222,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"bounds":{"left":0.51840276,"top":0.5977778,"width":0.0048611113,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DATE_ADD(created_at, INTERVAL","depth":17,"bounds":{"left":0.5232639,"top":0.5977778,"width":0.15416667,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":17,"bounds":{"left":0.67743057,"top":0.5977778,"width":0.0052083335,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"YEAR)","depth":17,"bounds":{"left":0.6826389,"top":0.5977778,"width":0.029861111,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"WHERE","depth":17,"bounds":{"left":0.44375,"top":0.61722225,"width":0.024652777,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at IS","depth":17,"bounds":{"left":0.46840277,"top":0.61722225,"width":0.074652776,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"NULL","depth":17,"bounds":{"left":0.54305553,"top":0.61722225,"width":0.02013889,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"AND","depth":17,"bounds":{"left":0.45347223,"top":0.63611114,"width":0.014930556,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"created_at","depth":17,"bounds":{"left":0.46840277,"top":0.63611114,"width":0.059722222,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":">=","depth":17,"bounds":{"left":0.528125,"top":0.63611114,"width":0.010069445,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DATE_SUB(NOW(), INTERVAL","depth":17,"bounds":{"left":0.5381944,"top":0.63611114,"width":0.12951389,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":17,"bounds":{"left":0.66770834,"top":0.63611114,"width":0.0048611113,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"YEAR);","depth":17,"bounds":{"left":0.67256945,"top":0.63611114,"width":0.034722224,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"bounds":{"left":0.9409722,"top":0.43388888,"width":0.023611112,"height":0.039444443},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"The","depth":17,"bounds":{"left":0.43263888,"top":0.69166666,"width":0.019791666,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"endOfDay()","depth":18,"bounds":{"left":0.45555556,"top":0.6944444,"width":0.05,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"adjustment is a nice touch that should be preserved if you keep the chunk approach; raw SQL would lose that. But even a chunk-based approach can batch the updates with a single","depth":17,"bounds":{"left":0.43263888,"top":0.69166666,"width":0.528125,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"whereIn","depth":18,"bounds":{"left":0.7986111,"top":0.7177778,"width":0.035069443,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"on IDs per chunk rather than one query per row.","depth":17,"bounds":{"left":0.43263888,"top":0.715,"width":0.53506947,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Fix this →","depth":17,"bounds":{"left":0.43263888,"top":0.77944446,"width":0.043055557,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Fix this →","depth":18,"bounds":{"left":0.43263888,"top":0.77944446,"width":0.043055557,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"2. NudgeExpirationWarningNotification — side-effect in via()","depth":16,"bounds":{"left":0.43263888,"top":0.8577778,"width":0.5375,"height":0.025},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2.","depth":17,"bounds":{"left":0.43263888,"top":0.85833335,"width":0.013888889,"height":0.023333333},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"NudgeExpirationWarningNotification","depth":18,"bounds":{"left":0.44895834,"top":0.85888886,"width":0.24861111,"height":0.023333333},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— side-effect in","depth":17,"bounds":{"left":0.7,"top":0.85833335,"width":0.09652778,"height":0.023333333},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"via()","depth":18,"bounds":{"left":0.79895836,"top":0.85888886,"width":0.036805555,"height":0.023333333},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app/Notifications/NudgeExpirationWarningNotification.php:15–29","depth":18,"bounds":{"left":0.4357639,"top":0.90555555,"width":0.30868056,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$formattedExpiresAt","depth":18,"bounds":{"left":0.4357639,"top":0.94666666,"width":0.094444446,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"is set in","depth":17,"bounds":{"left":0.53368056,"top":0.9438889,"width":0.039930556,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"via()","depth":18,"bounds":{"left":0.57708335,"top":0.94666666,"width":0.024652777,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and consumed in","depth":17,"bounds":{"left":0.60520834,"top":0.9438889,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"toPostmark()","depth":18,"bounds":{"left":0.690625,"top":0.94666666,"width":0.059722222,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". This creates an implicit ordering dependency. Laravel does call","depth":17,"bounds":{"left":0.43263888,"top":0.9438889,"width":0.5309028,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"via()","depth":18,"bounds":{"left":0.51319444,"top":0.97,"width":0.025,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"first, but if this notification is ever queued or serialised the state won't survive. Move the formatting logic into","depth":17,"bounds":{"left":0.43263888,"top":0.9672222,"width":0.5072917,"height":0.032777786},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"toPostmark()","depth":18,"bounds":{"left":0.52881944,"top":0.99333334,"width":0.059722222,"height":0.0066666603},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(or a private helper called from there) to make it self-contained:","depth":17,"bounds":{"left":0.5920139,"top":0.9905556,"width":0.28784722,"height":0.009444416},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"public","depth":17,"bounds":{"left":0.44375,"top":1.0,"width":0.029861111,"height":-0.048333287},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"function","depth":17,"bounds":{"left":0.47847223,"top":1.0,"width":0.039930556,"height":-0.048333287},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"toPostmark","depth":17,"bounds":{"left":0.5232639,"top":1.0,"width":0.049652778,"height":-0.048333287},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"():","depth":17,"bounds":{"left":0.5729167,"top":1.0,"width":0.02013889,"height":-0.048333287},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"EmailWithTemplate","depth":17,"bounds":{"left":0.59305555,"top":1.0,"width":0.084375,"height":-0.048333287},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":17,"bounds":{"left":0.44375,"top":1.0,"width":0.019791666,"height":-0.06777775},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"bounds":{"left":0.46354166,"top":1.0,"width":0.0048611113,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"email","depth":17,"bounds":{"left":0.46840277,"top":1.0,"width":0.025,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"bounds":{"left":0.49340278,"top":1.0,"width":0.014930556,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"new","depth":17,"bounds":{"left":0.5083333,"top":1.0,"width":0.014930556,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"EmailWithTemplate","depth":17,"bounds":{"left":0.528125,"top":1.0,"width":0.08472222,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"bounds":{"left":0.6128472,"top":1.0,"width":0.0048611113,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"bounds":{"left":0.6177083,"top":1.0,"width":0.0052083335,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"nudge-expiration-warning","depth":17,"bounds":{"left":0.62291664,"top":1.0,"width":0.119444445,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"bounds":{"left":0.7423611,"top":1.0,"width":0.0048611113,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":");","depth":17,"bounds":{"left":0.44375,"top":1.0,"width":0.31354168,"height":-0.0866667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"search","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"this","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"nudge","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getActivitySearch","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"();","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expiresAt","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"this","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"nudge","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getExpiresAt","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"();","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"email","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"setData","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"([","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"nudge_name","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=>","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"search","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getName","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(),","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=>","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expiresAt","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"format","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"F j, Y","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"),","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// timezone formatting can be passed separately","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"search_url","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=>","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"route","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"show.ondemand.page","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"),\n ]);","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"return","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"$","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"email","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";\n}","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"bounds":{"left":0.9409722,"top":1.0,"width":0.023611112,"height":-0.038333297},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Fix this →","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Fix this →","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"3. squashNudgeRuns semantics change","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"squashNudgeRuns","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"semantics change","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app/Console/Commands/RemoveExpiredNudgesCommand.php:95","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The condition changed from","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"whereNull('expires_at')","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(only \"never-expiring\" nudges) to","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"where('expires_at', '>', now())","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(all active nudges). Since nudges no longer have a \"never expire\" option this makes sense, but it's worth confirming: previously only nudges that had","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_in = EXPIRE_NEVER","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"were squashed, now","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"all active nudges","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"are candidates. If that's intentional, a short comment explaining the rationale would help future readers.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"4. SendNudgeExpirationWarningsCommand — missing Sentry capture","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SendNudgeExpirationWarningsCommand","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— missing Sentry capture","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app/Console/Commands/SendNudgeExpirationWarningsCommand.php:56–64","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"RemoveExpiredNudgesCommand","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"reports exceptions to Sentry;","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SendNudgeExpirationWarningsCommand","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"only logs them. If sending a warning fails silently in production you won't get an alert. Consider adding","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sentry::captureException($e)","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in the","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"catch","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"block, consistent with the sibling command.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Fix this →","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Fix this →","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"5. Minor — Command::SUCCESS constant","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"5. Minor —","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Command::SUCCESS","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"constant","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app/Console/Commands/SendNudgeExpirationWarningsCommand.php:79","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"return","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Should be","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"return Command::SUCCESS;","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"for readability and to match Laravel convention.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"6. NudgeRepository — expires_at IS NULL edge case","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"6.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"NudgeRepository","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"—","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at IS NULL","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"edge case","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"app/Component/Nudge/Repository/NudgeRepository.php:123","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"->","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"where","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":">","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"now","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"())","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy","depth":17,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"This silently excludes any nudge with a","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"NULL","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"expires_at","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(which should be zero after the migration runs, but worth noting). If the migration ever fails mid-run, affected nudges would stop triggering until the migration is re-run. Adding a note in the migration's docblock about this dependency would help with incident response.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"7. Email template — link to specific section","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"7. Email template — link to specific section","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"resources/views/emails/postmark-templates/email/nudge-expiration-warning.email.mustache","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The email instructs users to go to \"Jiminny → On Demand → Saved Searches\" but","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"search_url","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"points to the generic On Demand page. If a direct URL to the Saved Searches tab is available, using it would reduce friction for the user.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Summary","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Summary","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Severity","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Issue","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Medium","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Migration does N+1 UPDATEs — use bulk queries","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Medium","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"via()","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-9073727195732328218
|
6660116804992912009
|
click
|
accessibility
|
NULL
|
Platform Sprint 2 Q2 - Platform Team - Scrum Board Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
JY-20489 | Optimize Nudges - Phase 2 by yalokin-jiminny · Pull Request #11997 · jiminny/app
JY-20489 | Optimize Nudges - Phase 2 by yalokin-jiminny · Pull Request #11997 · jiminny/app
New Tab
New Tab
AI reports promotion pages by nikolay-yankov · Pull Request #11998 · jiminny/app
AI reports promotion pages by nikolay-yankov · Pull Request #11998 · jiminny/app
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
Close tab
Jiminny
Jiminny
Userpilot | Nudge-created
Userpilot | Nudge-created
Summary - app in Jiminny SonarQube Cloud
Summary - app in Jiminny SonarQube Cloud
Pipelines - jiminny/app
Pipelines - jiminny/app
New Tab
New Tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
Issues(g then i)
Pull requests
Repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (31)
Pull requests
(
31
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (32)
Security and quality
(
32
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
Review requested
Review requested
nikolaybiaivanov
nikolaybiaivanov
requested your review on this pull request.
Add your review
Add your review
JY-9712 | Nuges to expire after one year #11981 Edit title
JY-9712 | Nuges to expire after one year
#
11981
Edit title
Unable to merge
Unable to merge
Code
Code
Open
nikolaybiaivanov
nikolaybiaivanov
wants to merge 14 commits into
master
master
from
JY-9712-change-forever-nudges-to-1-year-expiration
JY-9712-change-forever-nudges-to-1-year-expiration
Copy head branch name to clipboard
Lines changed: 1276 additions & 1668 deletions
Conversation (11)
Conversation
(
11
)
Commits (14)
Commits
(
14
)
Checks (3)
Checks
(
3
)
Files changed (33)
Files changed
(
33
)
Conversation
Conversation
@nikolaybiaivanov
Show options
nikolaybiaivanov commented last week
nikolaybiaivanov
nikolaybiaivanov
commented
last week
last week
JIRA: JY-9712
JIRA:
JY-9712
JY-9712
Deployment notes:
Deployment notes:
None
Add or remove reactions
@nikolaybiaivanov
Show options
Author
nikolaybiaivanov commented last week
nikolaybiaivanov
nikolaybiaivanov
commented
last week
last week
@claude
@claude
Add or remove reactions
react with eyes
👀
1
@claude
Show options
claude Bot commented last week •
claude
claude
Bot
commented
last week
last week
•
edited
edited
Claude finished
@nikolaybiaivanov
@nikolaybiaivanov
's task in 2m 9s
——
View job
View job
Code Review
Code Review
Completed task
Gather context and read changed files
Completed task
Review NudgeFactory.php changes
Completed task
Review NudgeRepository.php changes
Completed task
Review RemoveExpiredNudgesCommand.php changes
Completed task
Review SendNudgeExpirationWarningsCommand.php (new)
Completed task
Review NudgeController.php changes
Completed task
Review Nudge.php model changes
Completed task
Review NudgeExpirationWarningNotification.php (new)
Completed task
Review migration file (new)
Completed task
Review email templates (new)
Completed task
Review Kernel.php changes
Completed task
Post final review
Overall this is a well-structured refactor. The removal of the legacy
expires_in
integer enum in favour of a real datetime
expires_at
, the migration for existing data, and the warning-email pipeline all fit together cleanly. A few things worth looking at:
1. Migration: per-row UPDATE is slow at scale
1. Migration: per-row UPDATE is slow at scale
database/migrations/2026_04_16_121204_migrate_existing_nudges_expiration_data.php
The migration issues one
UPDATE
per nudge inside a chunk loop. For large tables this will be very slow. You can replace it with two bulk updates and drop the PHP loop entirely:
--
Nudges older than 1 year → expire in 8 days
UPDATE
nudges
SET
expires_at
=
DATE_ADD(NOW(), INTERVAL
8
DAY)
WHERE
expires_at IS
NULL
AND
created_at
<
DATE_SUB(NOW(), INTERVAL
1
YEAR);
--
Newer nudges → expire 1 year after creation
UPDATE
nudges
SET
expires_at
=
DATE_ADD(created_at, INTERVAL
1
YEAR)
WHERE
expires_at IS
NULL
AND
created_at
>=
DATE_SUB(NOW(), INTERVAL
1
YEAR);
Copy
The
endOfDay()
adjustment is a nice touch that should be preserved if you keep the chunk approach; raw SQL would lose that. But even a chunk-based approach can batch the updates with a single
whereIn
on IDs per chunk rather than one query per row.
Fix this →
Fix this →
2. NudgeExpirationWarningNotification — side-effect in via()
2.
NudgeExpirationWarningNotification
— side-effect in
via()
app/Notifications/NudgeExpirationWarningNotification.php:15–29
$formattedExpiresAt
is set in
via()
and consumed in
toPostmark()
. This creates an implicit ordering dependency. Laravel does call
via()
first, but if this notification is ever queued or serialised the state won't survive. Move the formatting logic into
toPostmark()
(or a private helper called from there) to make it self-contained:
public
function
toPostmark
():
EmailWithTemplate
{
$
email
=
new
EmailWithTemplate
(
'
nudge-expiration-warning
'
);
$
search
=
$
this
->
nudge
->
getActivitySearch
();
$
expiresAt
=
$
this
->
nudge
->
getExpiresAt
();
$
email
->
setData
([
'
nudge_name
'
=>
$
search
->
getName
(),
'
expires_at
'
=>
$
expiresAt
->
format
(
'
F j, Y
'
),
// timezone formatting can be passed separately
'
search_url
'
=>
route
(
'
show.ondemand.page
'
),
]);
return
$
email
;
}
Copy
Fix this →
Fix this →
3. squashNudgeRuns semantics change
3.
squashNudgeRuns
semantics change
app/Console/Commands/RemoveExpiredNudgesCommand.php:95
The condition changed from
whereNull('expires_at')
(only "never-expiring" nudges) to
where('expires_at', '>', now())
(all active nudges). Since nudges no longer have a "never expire" option this makes sense, but it's worth confirming: previously only nudges that had
expires_in = EXPIRE_NEVER
were squashed, now
all active nudges
are candidates. If that's intentional, a short comment explaining the rationale would help future readers.
4. SendNudgeExpirationWarningsCommand — missing Sentry capture
4.
SendNudgeExpirationWarningsCommand
— missing Sentry capture
app/Console/Commands/SendNudgeExpirationWarningsCommand.php:56–64
RemoveExpiredNudgesCommand
reports exceptions to Sentry;
SendNudgeExpirationWarningsCommand
only logs them. If sending a warning fails silently in production you won't get an alert. Consider adding
Sentry::captureException($e)
in the
catch
block, consistent with the sibling command.
Fix this →
Fix this →
5. Minor — Command::SUCCESS constant
5. Minor —
Command::SUCCESS
constant
app/Console/Commands/SendNudgeExpirationWarningsCommand.php:79
return
0
;
Copy
Should be
return Command::SUCCESS;
for readability and to match Laravel convention.
6. NudgeRepository — expires_at IS NULL edge case
6.
NudgeRepository
—
expires_at IS NULL
edge case
app/Component/Nudge/Repository/NudgeRepository.php:123
->
where
(
'
expires_at
'
,
'
>
'
,
now
())
Copy
This silently excludes any nudge with a
NULL
expires_at
(which should be zero after the migration runs, but worth noting). If the migration ever fails mid-run, affected nudges would stop triggering until the migration is re-run. Adding a note in the migration's docblock about this dependency would help with incident response.
7. Email template — link to specific section
7. Email template — link to specific section
resources/views/emails/postmark-templates/email/nudge-expiration-warning.email.mustache
The email instructs users to go to "Jiminny → On Demand → Saved Searches" but
search_url
points to the generic On Demand page. If a direct URL to the Saved Searches tab is available, using it would reduce friction for the user.
Summary
Summary
Severity
Issue
Medium
Migration does N+1 UPDATEs — use bulk queries
Medium
via()...
|
76450
|
|
52770
|
1143
|
9
|
2026-04-20T07:36:35.491363+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776670595491_m2.jpg...
|
PhpStorm
|
faVsco.js – TrackAutomatedReportGeneratedEventTest faVsco.js – TrackAutomatedReportGeneratedEventTest.php...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavigarecodeLaravelTOOISWindowmelpFV PhostormVIewINavigarecodeLaravelTOOISWindowmelpFV faVsco.js( #11894 on JY-18909-automated-reports-aslProiect v> scriots•→ storadev tests> → Feature>D Intearation, _ Servicesv D UnitActionsn ComnonentN ConfiauratD ConsoleN Contractsh DomainDDTOD EnumsDJ EventsCa ExceptionsJTxturesa GuardsC Helpers0 Hito• IntegrationulinteractionsMJobs> D ActivitvM AiAutor> Audio• M Automaic) Crea© Requc) Redu© Sendc) SendIm CalendaD Crmm DealPiclm Mailhoy© Playbook.php= custom.log=laravel.log4 SF [jiminny@localhost]A HS_local (jiminny@localhost]& console [PKol)4 console [EU] X© Service.phpyrelakepository.ohp© ReportController.phpA console [STAGING]Tx: Autovdojiminny vrom business processes where 1d = 6024.027 A9 A23 V3 V 105 ^©) Automatedreporiskepository.php© TrackProviderInstalledEvent.phpC) CreateActivityLoggedEvent.php(C) UserPilotActivityListener.pho© ActivityLoaged.php159311159411595)rom business process stages where stage 1d = 16352.rom business process stages where business process 1d = 6024:rom stages where team_id = 459:TrackAutomatedReportGeneratedEventTestn' ELSE "I END) AS usen idlv v Test Results104 msS chTv 7 tests passed 7 tests total, 104 ms[[URL_WITH_CREDENTIALS] No code coverage driver availablenk hut thene wene iccueclTests: 7, Assertions: 35, PHPUnit Warnings: 1StreamirC) TeamC) Telepho) Mlicor.© ImportR© SasVisibv C Listeners> D Activitie> M Audiov M Automatv M UserPilotProcece finiched with exit code 1r)' ELSE "• END) AS user idS+hde.CoutomotodDonontoConui.ol(c) TrackAutomatedReport> M AutoScorev DCrmsmothodtTnctralredit +nowytvX Reject File 0*€c) Bootstrao ntedrationAootie100% LzMon 20 AOr 10:30.30U TrackAutomatedReportGeneratedEventTest vCascadee Group Photo URL R• Retactor UserPilot 1I+O •There was 1 error.orts UserPilot TrackAutomatedReportGeneratedEventTest:testHandleTracksCreatorForA)/home//iminny/tests/Unit/Listeners/AutomatedReports/UserPilot/TrackAutomatedReportGeneratedEventrest.php:182ERRORS, Assertions: 35, Errors: 1, PHPUnit Warnings: 1. Once fixed run it and keep fixing and runnign until all are passingSearched getFrequency in app/Models/AutomatedReport.php (1) ›ThouahtsRead TrackAutomatedReportGeneratedEventTest.php #L172-201Thoughts•TrackAutomatedReportGeneratedEventTest.phpInoughts1 ateh epo its/userP/10t/Tra kAutonateddeportGeneratedEventTest. php -по-PHPUnit 11.5.55 by Sebastian Bergmann and contributors.8.3.30Runtime:tion. PHP 8,3.30mv/ohounit ynl7/7 11009-)Time: 00:00.874. Memory: 66.06 MBlAsk anvthina (&4L)Kimi K2 5)Tacts naccod. 7 (md* Reiect allAccent alliWN Windsurf TeamoUTE.8Po 4 spaces...
|
NULL
|
-9073575315021020546
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIewINavigarecodeLaravelTOOISWindowmelpFV PhostormVIewINavigarecodeLaravelTOOISWindowmelpFV faVsco.js( #11894 on JY-18909-automated-reports-aslProiect v> scriots•→ storadev tests> → Feature>D Intearation, _ Servicesv D UnitActionsn ComnonentN ConfiauratD ConsoleN Contractsh DomainDDTOD EnumsDJ EventsCa ExceptionsJTxturesa GuardsC Helpers0 Hito• IntegrationulinteractionsMJobs> D ActivitvM AiAutor> Audio• M Automaic) Crea© Requc) Redu© Sendc) SendIm CalendaD Crmm DealPiclm Mailhoy© Playbook.php= custom.log=laravel.log4 SF [jiminny@localhost]A HS_local (jiminny@localhost]& console [PKol)4 console [EU] X© Service.phpyrelakepository.ohp© ReportController.phpA console [STAGING]Tx: Autovdojiminny vrom business processes where 1d = 6024.027 A9 A23 V3 V 105 ^©) Automatedreporiskepository.php© TrackProviderInstalledEvent.phpC) CreateActivityLoggedEvent.php(C) UserPilotActivityListener.pho© ActivityLoaged.php159311159411595)rom business process stages where stage 1d = 16352.rom business process stages where business process 1d = 6024:rom stages where team_id = 459:TrackAutomatedReportGeneratedEventTestn' ELSE "I END) AS usen idlv v Test Results104 msS chTv 7 tests passed 7 tests total, 104 ms[[URL_WITH_CREDENTIALS] No code coverage driver availablenk hut thene wene iccueclTests: 7, Assertions: 35, PHPUnit Warnings: 1StreamirC) TeamC) Telepho) Mlicor.© ImportR© SasVisibv C Listeners> D Activitie> M Audiov M Automatv M UserPilotProcece finiched with exit code 1r)' ELSE "• END) AS user idS+hde.CoutomotodDonontoConui.ol(c) TrackAutomatedReport> M AutoScorev DCrmsmothodtTnctralredit +nowytvX Reject File 0*€c) Bootstrao ntedrationAootie100% LzMon 20 AOr 10:30.30U TrackAutomatedReportGeneratedEventTest vCascadee Group Photo URL R• Retactor UserPilot 1I+O •There was 1 error.orts UserPilot TrackAutomatedReportGeneratedEventTest:testHandleTracksCreatorForA)/home//iminny/tests/Unit/Listeners/AutomatedReports/UserPilot/TrackAutomatedReportGeneratedEventrest.php:182ERRORS, Assertions: 35, Errors: 1, PHPUnit Warnings: 1. Once fixed run it and keep fixing and runnign until all are passingSearched getFrequency in app/Models/AutomatedReport.php (1) ›ThouahtsRead TrackAutomatedReportGeneratedEventTest.php #L172-201Thoughts•TrackAutomatedReportGeneratedEventTest.phpInoughts1 ateh epo its/userP/10t/Tra kAutonateddeportGeneratedEventTest. php -по-PHPUnit 11.5.55 by Sebastian Bergmann and contributors.8.3.30Runtime:tion. PHP 8,3.30mv/ohounit ynl7/7 11009-)Time: 00:00.874. Memory: 66.06 MBlAsk anvthina (&4L)Kimi K2 5)Tacts naccod. 7 (md* Reiect allAccent alliWN Windsurf TeamoUTE.8Po 4 spaces...
|
NULL
|
|
39040
|
795
|
4
|
2026-04-16T13:27:58.566791+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-16/1776 /Users/lukas/.screenpipe/data/data/2026-04-16/1776346078566_m2.jpg...
|
Boosteroid
|
Boosteroid
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
152837850462681/115ШImperial Age--Villager Created 152837850462681/115ШImperial Age--Villager Created---Castle Built---House Built---Town Center Built----Elite Skirmisher Created-Research Gambesons (Cost: 25 D,50 ₴)Militia-line and Fire Lancers +1 piercearmor.(Hotkey: Z)koval ikukas (DravidiansT 2/9o0/a 0l)4 Roger II of Sicily: 5197/51973 Anastasios I Dikoros: 4631/46316 Emperor Karel IV: 4397/43975 Manuel I: 4374/43748 Mundzuk the Hun: 4235/4235Zbigniew Olesnicki: 3856/38561 kovaliklukas: 3634/36347 Themistocles: 3563/3563BEBGH...
|
NULL
|
-9072695999245831537
|
NULL
|
click
|
ocr
|
NULL
|
152837850462681/115ШImperial Age--Villager Created 152837850462681/115ШImperial Age--Villager Created---Castle Built---House Built---Town Center Built----Elite Skirmisher Created-Research Gambesons (Cost: 25 D,50 ₴)Militia-line and Fire Lancers +1 piercearmor.(Hotkey: Z)koval ikukas (DravidiansT 2/9o0/a 0l)4 Roger II of Sicily: 5197/51973 Anastasios I Dikoros: 4631/46316 Emperor Karel IV: 4397/43975 Manuel I: 4374/43748 Mundzuk the Hun: 4235/4235Zbigniew Olesnicki: 3856/38561 kovaliklukas: 3634/36347 Themistocles: 3563/3563BEBGH...
|
NULL
|
|
62969
|
1360
|
17
|
2026-04-21T08:23:52.583671+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-21/1776 /Users/lukas/.screenpipe/data/data/2026-04-21/1776759832583_m2.jpg...
|
iTerm2
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivitsMoreSlackVievJiminny...y• Dratts & cen ActivitsMoreSlackVievJiminny...y• Dratts & cent8 DirectoriesAb External connections Starred@jiminny-x-integrati….=nlattorm-inner-team( Channels# ai-chapter# alertsihackendl# confusion-clinica curiosiiy ab# engineering# frontend# general# infra-changes# jiminny-bg# platform-tickets# product launchesi random# releasesdsunbort# thank-yous# the people of iimi..A Direct messageswVasl Vasilev. Nikolay NikolovR Aneliva Angelova@ Galva Dimitrove2 Stefka StovanovaAa Stovan Tomov3 Aneliya Angelova. ...3 Stovan TaneyNikolay lvanowal Ves::: Apps$ Jira CloudTctMistonWindowHelpQ Search Jiminny Ince Vasil VasilevMessagest Add canvasUr Files& PinsVasil Vasilev 12:00 PMThursday. April 16thLukas Kovalik 12:01 PMняма проолемukas Kovallk 1.42 pMВаско ако имаш минутка, имам един бръз въпрос покрай integration-appVasl Vasilev 1:42 PMILukas Kovallk 1:48 PNсори имаш 5 мин или след 5 мин? сVasil Vasiley 1:49 PMTVK CIMLukas Kovallik 1:49 PMне е май спешно, ще ти пиша и когато ти е улобно, става лума за auth promiseVasll Vasiley 1:49 PMтояоваха ми 5 мин, че тоия кол. и внимавам какво тоиясега вече мога ла говорязвьнкаиA huddle happened 1:49 PMVacil Vaciley 4.cлomlif (connection && (connection.disconnected === false || connection.connected === true)) ≤if (connection && (connection?.disconnected === false ll connection?.connected === true)) {YesterdavvVacil Vaciley Ao1 oмlЛукаш приветіnovermind мло ше та занимарамсега видях колко е станал часаTodavvVacil Vacilev 11.01AMЛукаш сигнализипай като си надколоїда те питам за един event в Salesforce::saveActivityuukas Kovalik 11.22 AMna Tvka CIMMessage Vasil Vasilev+ ДаA HS_local [jiminny@localhost]console [PKol)& console [EU] Xdo jiminny v027 A9 A23 V3 X105 ^ Vactivity - Alex Howes [EMAIL] created 2026-01-26ashosupportroom.com 2026-03-24owner id THEN ' (owner)' ELSE •• END) AS user idam_idne"hubspot":ture id = 40:" ownen: 18101. crm: 47re 1d = 477:owner id THEN " owner): ELSE II END) AS user 1dam_id• = 'integration-app':= 7594349;re opportunity_id = 7594349 order by created_ at desc:Ire 10 = 0024s where stage id = 16352:s where business process id = 6024:459:1111wner id THEN ' (owner)' ELSE •• END) AS user_idam id'hubsoottdsname. CnllNTt as entsuppont Dally • In 3n 31m100% S2Tue 21 Apr 11:23:52CascadeReview Planhat Intear+0 ..logged-activityshared-activityplayed-activitycommented-on-activityselt-coached-activityActivities/Coaching/Planhat/CreateCommentedEvent.phpActivities/Coaching/Planhat/CreateSelfCoachedEvent.phpactivity payloadactivity payloadacarvectes/coaching/rlannac/creacenanagercoacheocvent.ongactivity payloadscorecaro-recervedacalvecses/coaching/rtannac/creacecoachangreeobackkecervedcvent.pnpactivity payloadcoachingFeedback-completedActivities/Coaching/Planhat/CreateCoachingFeedbackCompletedEvent.phpactivity payloadcoaching-focus-receivedActivities/Coaching/Planhat/CreateCoachingFocusReceivedEvent.phpactivity payloadcoaching-focus-comoletedActivities/Coaching/Planhat/CreateCoachingFocusCompletedEvent.phpactivity pavloadActiViLy payload shape (trom PlannatactivlcyListener.pnp: 25-5/): provider, channel, duration, status, recordingstatus, language, private, internal, logged, averageScore, date.Patterns Used Across Listenersitlcontiaservices.planhat.enabled) return;try {Sth1s->planhatserv1ce→>track(suser, 'event-name, Spayload);catch (GuzzleException Se\Log::error('Failed to track event', ['exception' = $el);Sthis->release(3600): // retry in somAll are ShouldQueue and run on the QUEUE DELAYABLE queueSummary for the new "I am interested" eventTo add a new event. the established pattern is1. Backend endpoint (new controller action) that resolves the authenticated User and calls SplanhatService-strack(Suser. "interested-<something>', Spayload) directly — OR dispatches an event + listener if it's triggered by a domain event rather than a Ul click2. Event name kebab-case strina (e.a. clicked-interested. expressed-interest ))3. Payload any relevant context (feature name, source, etc.) that goes into the info field.4. Frontend posts to the new endpoint on clickSince this is a direct user UlI click (not a side-effect of a domain event), a thin controller calling PlanhatService: : track() is the simplest fit - nonew listener neededWant me to proceed with implementing the new endpoint? If so, please confirm• The exact event name to send to Planhat (e.g. clicked-interested, interested-clicked ).• Where in the Ul is the button / what context should be in the payload (e.a. feature id, source page).• Preferred route path (e.g. POST /api/v1/planhat/track-interest ).lets add it cimilar as Sthic-snlanhatService-strackl1; Lots cali t astomated reporte treck ierestW Windsurf ToamPa 4 sp...
|
NULL
|
-9071959745814101834
|
NULL
|
click
|
ocr
|
NULL
|
ActivitsMoreSlackVievJiminny...y• Dratts & cen ActivitsMoreSlackVievJiminny...y• Dratts & cent8 DirectoriesAb External connections Starred@jiminny-x-integrati….=nlattorm-inner-team( Channels# ai-chapter# alertsihackendl# confusion-clinica curiosiiy ab# engineering# frontend# general# infra-changes# jiminny-bg# platform-tickets# product launchesi random# releasesdsunbort# thank-yous# the people of iimi..A Direct messageswVasl Vasilev. Nikolay NikolovR Aneliva Angelova@ Galva Dimitrove2 Stefka StovanovaAa Stovan Tomov3 Aneliya Angelova. ...3 Stovan TaneyNikolay lvanowal Ves::: Apps$ Jira CloudTctMistonWindowHelpQ Search Jiminny Ince Vasil VasilevMessagest Add canvasUr Files& PinsVasil Vasilev 12:00 PMThursday. April 16thLukas Kovalik 12:01 PMняма проолемukas Kovallk 1.42 pMВаско ако имаш минутка, имам един бръз въпрос покрай integration-appVasl Vasilev 1:42 PMILukas Kovallk 1:48 PNсори имаш 5 мин или след 5 мин? сVasil Vasiley 1:49 PMTVK CIMLukas Kovallik 1:49 PMне е май спешно, ще ти пиша и когато ти е улобно, става лума за auth promiseVasll Vasiley 1:49 PMтояоваха ми 5 мин, че тоия кол. и внимавам какво тоиясега вече мога ла говорязвьнкаиA huddle happened 1:49 PMVacil Vaciley 4.cлomlif (connection && (connection.disconnected === false || connection.connected === true)) ≤if (connection && (connection?.disconnected === false ll connection?.connected === true)) {YesterdavvVacil Vaciley Ao1 oмlЛукаш приветіnovermind мло ше та занимарамсега видях колко е станал часаTodavvVacil Vacilev 11.01AMЛукаш сигнализипай като си надколоїда те питам за един event в Salesforce::saveActivityuukas Kovalik 11.22 AMna Tvka CIMMessage Vasil Vasilev+ ДаA HS_local [jiminny@localhost]console [PKol)& console [EU] Xdo jiminny v027 A9 A23 V3 X105 ^ Vactivity - Alex Howes [EMAIL] created 2026-01-26ashosupportroom.com 2026-03-24owner id THEN ' (owner)' ELSE •• END) AS user idam_idne"hubspot":ture id = 40:" ownen: 18101. crm: 47re 1d = 477:owner id THEN " owner): ELSE II END) AS user 1dam_id• = 'integration-app':= 7594349;re opportunity_id = 7594349 order by created_ at desc:Ire 10 = 0024s where stage id = 16352:s where business process id = 6024:459:1111wner id THEN ' (owner)' ELSE •• END) AS user_idam id'hubsoottdsname. CnllNTt as entsuppont Dally • In 3n 31m100% S2Tue 21 Apr 11:23:52CascadeReview Planhat Intear+0 ..logged-activityshared-activityplayed-activitycommented-on-activityselt-coached-activityActivities/Coaching/Planhat/CreateCommentedEvent.phpActivities/Coaching/Planhat/CreateSelfCoachedEvent.phpactivity payloadactivity payloadacarvectes/coaching/rlannac/creacenanagercoacheocvent.ongactivity payloadscorecaro-recervedacalvecses/coaching/rtannac/creacecoachangreeobackkecervedcvent.pnpactivity payloadcoachingFeedback-completedActivities/Coaching/Planhat/CreateCoachingFeedbackCompletedEvent.phpactivity payloadcoaching-focus-receivedActivities/Coaching/Planhat/CreateCoachingFocusReceivedEvent.phpactivity payloadcoaching-focus-comoletedActivities/Coaching/Planhat/CreateCoachingFocusCompletedEvent.phpactivity pavloadActiViLy payload shape (trom PlannatactivlcyListener.pnp: 25-5/): provider, channel, duration, status, recordingstatus, language, private, internal, logged, averageScore, date.Patterns Used Across Listenersitlcontiaservices.planhat.enabled) return;try {Sth1s->planhatserv1ce→>track(suser, 'event-name, Spayload);catch (GuzzleException Se\Log::error('Failed to track event', ['exception' = $el);Sthis->release(3600): // retry in somAll are ShouldQueue and run on the QUEUE DELAYABLE queueSummary for the new "I am interested" eventTo add a new event. the established pattern is1. Backend endpoint (new controller action) that resolves the authenticated User and calls SplanhatService-strack(Suser. "interested-<something>', Spayload) directly — OR dispatches an event + listener if it's triggered by a domain event rather than a Ul click2. Event name kebab-case strina (e.a. clicked-interested. expressed-interest ))3. Payload any relevant context (feature name, source, etc.) that goes into the info field.4. Frontend posts to the new endpoint on clickSince this is a direct user UlI click (not a side-effect of a domain event), a thin controller calling PlanhatService: : track() is the simplest fit - nonew listener neededWant me to proceed with implementing the new endpoint? If so, please confirm• The exact event name to send to Planhat (e.g. clicked-interested, interested-clicked ).• Where in the Ul is the button / what context should be in the payload (e.a. feature id, source page).• Preferred route path (e.g. POST /api/v1/planhat/track-interest ).lets add it cimilar as Sthic-snlanhatService-strackl1; Lots cali t astomated reporte treck ierestW Windsurf ToamPa 4 sp...
|
NULL
|
|
19180
|
405
|
31
|
2026-04-15T07:19:09.115849+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-15/1776 /Users/lukas/.screenpipe/data/data/2026-04-15/1776237549115_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
+FirefoxFileEditViewHistoryBookmarksProfilesToolsW +FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp| [Platform) Planning... 41 m left)meet.google.com/tgb-pyuf-dri?authuser=lukas.kovalik%40jiminny.comStefka Stoyanova (Presenting, annotating)ChromeFileHistoryHelpWed 15 Apr 10:19+++++jiminny.atlassian.net/jira/software/c/projects/JY/boards/37*2 WorkRelaunch to update88Q Search|+ CreateUpgradeAsk RovoSpaces / Jiminny (New)|Platform Team 884# Summary|& Timeline® BacklogActive sprintsCalendar2 Reports4 Testing Board& Forms• Components |⅘ Development|More 9Q Search board000+2EpicvTypevLabel vQuick filters vComplete sprintGroup: QueriesREADY FOR DEVIN DEV 3CODE REVIEW 1BLOCKEDQAPO ACCEPTANCEDEPLOY 11[Part2) Automatedreports with AskJiminnyAJ REPORTSIn DevAIBEFEQA11.5 1 .... =Evaluation for AIActivity TypesAUTO-DETECTED ACTIVI...Code Review1 П [PASSWORD_DOTS]=Fix downtimeduring to featureflag releaseSUPPORT TICKETSDeployed15 /) [PASSWORD_DOTS]=@ JY-19798X JY-20565A JY-18909+ CreateDelays in CRM...c...PLATFORM STABILITYIn Dev2 .000 =AI Review - Q1GROWTH - MAINTAIN OU…Deployed1• ••00=Jy-19982A JY-20553|No such column"Type' on entityGalya DimitrovaSterka Stoyanovazoom10:19 AM | [Platform] Planning | Session zLukas Kovalik18:55Apr 10:19:099*Nikolay Nikolov4 others...
|
NULL
|
-9071944731480213700
|
NULL
|
visual_change
|
ocr
|
NULL
|
+FirefoxFileEditViewHistoryBookmarksProfilesToolsW +FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp| [Platform) Planning... 41 m left)meet.google.com/tgb-pyuf-dri?authuser=lukas.kovalik%40jiminny.comStefka Stoyanova (Presenting, annotating)ChromeFileHistoryHelpWed 15 Apr 10:19+++++jiminny.atlassian.net/jira/software/c/projects/JY/boards/37*2 WorkRelaunch to update88Q Search|+ CreateUpgradeAsk RovoSpaces / Jiminny (New)|Platform Team 884# Summary|& Timeline® BacklogActive sprintsCalendar2 Reports4 Testing Board& Forms• Components |⅘ Development|More 9Q Search board000+2EpicvTypevLabel vQuick filters vComplete sprintGroup: QueriesREADY FOR DEVIN DEV 3CODE REVIEW 1BLOCKEDQAPO ACCEPTANCEDEPLOY 11[Part2) Automatedreports with AskJiminnyAJ REPORTSIn DevAIBEFEQA11.5 1 .... =Evaluation for AIActivity TypesAUTO-DETECTED ACTIVI...Code Review1 П [PASSWORD_DOTS]=Fix downtimeduring to featureflag releaseSUPPORT TICKETSDeployed15 /) [PASSWORD_DOTS]=@ JY-19798X JY-20565A JY-18909+ CreateDelays in CRM...c...PLATFORM STABILITYIn Dev2 .000 =AI Review - Q1GROWTH - MAINTAIN OU…Deployed1• ••00=Jy-19982A JY-20553|No such column"Type' on entityGalya DimitrovaSterka Stoyanovazoom10:19 AM | [Platform] Planning | Session zLukas Kovalik18:55Apr 10:19:099*Nikolay Nikolov4 others...
|
NULL
|
|
1896
|
40
|
36
|
2026-04-12T08:41:26.892956+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-12/1775 /Users/lukas/.screenpipe/data/data/2026-04-12/1775983286892_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PostmanFileEditViewWindowHelp8 Notion (JIRA, Eleve PostmanFileEditViewWindowHelp8 Notion (JIRA, Elevenlabs) ~Q SearchScreenPioeGET heathScreenPipe > healthnuo.localnost.50g0/stalsE DocsraramsHeaders 7 Body Scripts SettingsQuery ParamsKeyvalueV COLLECTIONS> AirTable> AppFlowy› Elevenlabs›oarminconnect› JiraLocalion-loeeer› Notion APIvuerieOwntracks• Paperless›Pinecone› Pocketv screenrioeGET searchGer healtr› Slack> Test FlaskBody Cookies Headers 5 Test ResultsE Raw D Preview * Debug with AlENVIRONMENIS>SPECS>ELOWSE Connect Git E Console lerminalobl100% (45Sun 12 Apr 11:41:26inviteUoeradeNo environmentC Save vshareSendCookiecNew chatQ SearchCustomizeao AruractssarreaDescrlouonBulk Edit ...Bulgarian citizenship application...Dawarich location tracking projectRecentsUnderstanding OpenRouter APl...Screenpipe query capabilities a.Screenpipe 14-day retention exp...Reddit homepage feed overviewDocker container not visible in c...DIA browser RAM configurationScreenpipe company backgroundDSK Bank Bulgaria secure invest...Building a comprehensive perso...moorune cooce lmelline eala t.a404 Not FoundChromecast remote volume butt...•2 ms • 250 B • Ce.g. Save Response **5Screenpipe free version guideSelf-hosted LLM alternatives to .Screenpipe database query setupRunning screenpipe for freeMac stuaio network switeh setupClaude extension in Vivaldi brow..General information requestRed dots on neck causesChange approvalQuerying Screenpipe with Claud...Setting up daily backups in Ugre..Upcoming remindersAccessing tools in Upen webulLaraverissue troublesnootingReminder to take rodny listSetting up reminders in Open W...NAS reverse proxy access issue...Reminders summary and manag...Self-hosted screen recording an...All cnalsLukas KovalikPro nianGlobalsVault Tools >00dChatCoworkCodeScreenpipe query capabilities and usage v%JY-20458%'ORDER BY timestamp;-- Slack messages about a topicSELECT timestamp, textFROM ocr_textJOIN frames ON ocr_text. frame_id = frames. idWHERE frames.app_name = 'Slack' AND text LIKE "%webhook%'ORDER BY timestamp DESC LIMIT 20;Via MCP (in Claude)If you've added the Screenpipe MCP to Claude.ai, you can just ask naturally:• "What was I working on in PhpStorm between 2pm and 4pm today?"• "Find any Slack messages about the Salesforce integration this week"• "How much time did I spend in the browser vs IDE today?"• "What was on my screen around the time I started the JY-20458 branch?"The MCP tools wrap the same REST API but Claude handles building the query for you.Practical queries for your workflowGiven you use PhpStorm, Windsurf, iTerm2, Arc, and Slack heavily:bash# "What branch was I on earlier?" - search iTerm2 outputcurl "http://localhost:3030/search?q=git+branch&app_name=iTerm2&1imit=20"# "What Laravel error did I see this morning?"cur1 "http://localhost:3030/search?q=ErrorException&app_name=PhpStorm"# "What did I read about in Arc today?"curl "http://localhost: 3030/search?content_type=ocr&app_name=Arc&start_time=2026-04-# Time on each app todaycurl "http://localhost:3030/stats/apps?start_time=2026-04-11T00:00:00"The /stats/apps and /raw_sq] endpoints are underutilized but give you the richest time-tracking data. Worth building a small daily summary script around those.Reply...+Sonnet 46 vclaude is Al and can make mistakes. rlease double-cneck responses....
|
NULL
|
-9071785758422304594
|
NULL
|
click
|
ocr
|
NULL
|
PostmanFileEditViewWindowHelp8 Notion (JIRA, Eleve PostmanFileEditViewWindowHelp8 Notion (JIRA, Elevenlabs) ~Q SearchScreenPioeGET heathScreenPipe > healthnuo.localnost.50g0/stalsE DocsraramsHeaders 7 Body Scripts SettingsQuery ParamsKeyvalueV COLLECTIONS> AirTable> AppFlowy› Elevenlabs›oarminconnect› JiraLocalion-loeeer› Notion APIvuerieOwntracks• Paperless›Pinecone› Pocketv screenrioeGET searchGer healtr› Slack> Test FlaskBody Cookies Headers 5 Test ResultsE Raw D Preview * Debug with AlENVIRONMENIS>SPECS>ELOWSE Connect Git E Console lerminalobl100% (45Sun 12 Apr 11:41:26inviteUoeradeNo environmentC Save vshareSendCookiecNew chatQ SearchCustomizeao AruractssarreaDescrlouonBulk Edit ...Bulgarian citizenship application...Dawarich location tracking projectRecentsUnderstanding OpenRouter APl...Screenpipe query capabilities a.Screenpipe 14-day retention exp...Reddit homepage feed overviewDocker container not visible in c...DIA browser RAM configurationScreenpipe company backgroundDSK Bank Bulgaria secure invest...Building a comprehensive perso...moorune cooce lmelline eala t.a404 Not FoundChromecast remote volume butt...•2 ms • 250 B • Ce.g. Save Response **5Screenpipe free version guideSelf-hosted LLM alternatives to .Screenpipe database query setupRunning screenpipe for freeMac stuaio network switeh setupClaude extension in Vivaldi brow..General information requestRed dots on neck causesChange approvalQuerying Screenpipe with Claud...Setting up daily backups in Ugre..Upcoming remindersAccessing tools in Upen webulLaraverissue troublesnootingReminder to take rodny listSetting up reminders in Open W...NAS reverse proxy access issue...Reminders summary and manag...Self-hosted screen recording an...All cnalsLukas KovalikPro nianGlobalsVault Tools >00dChatCoworkCodeScreenpipe query capabilities and usage v%JY-20458%'ORDER BY timestamp;-- Slack messages about a topicSELECT timestamp, textFROM ocr_textJOIN frames ON ocr_text. frame_id = frames. idWHERE frames.app_name = 'Slack' AND text LIKE "%webhook%'ORDER BY timestamp DESC LIMIT 20;Via MCP (in Claude)If you've added the Screenpipe MCP to Claude.ai, you can just ask naturally:• "What was I working on in PhpStorm between 2pm and 4pm today?"• "Find any Slack messages about the Salesforce integration this week"• "How much time did I spend in the browser vs IDE today?"• "What was on my screen around the time I started the JY-20458 branch?"The MCP tools wrap the same REST API but Claude handles building the query for you.Practical queries for your workflowGiven you use PhpStorm, Windsurf, iTerm2, Arc, and Slack heavily:bash# "What branch was I on earlier?" - search iTerm2 outputcurl "http://localhost:3030/search?q=git+branch&app_name=iTerm2&1imit=20"# "What Laravel error did I see this morning?"cur1 "http://localhost:3030/search?q=ErrorException&app_name=PhpStorm"# "What did I read about in Arc today?"curl "http://localhost: 3030/search?content_type=ocr&app_name=Arc&start_time=2026-04-# Time on each app todaycurl "http://localhost:3030/stats/apps?start_time=2026-04-11T00:00:00"The /stats/apps and /raw_sq] endpoints are underutilized but give you the richest time-tracking data. Worth building a small daily summary script around those.Reply...+Sonnet 46 vclaude is Al and can make mistakes. rlease double-cneck responses....
|
NULL
|
|
43294
|
920
|
74
|
2026-04-17T08:00:57.178657+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776412857178_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEdit→ViewHistoryCNikolay Nikolov (Prese FirefoxFileEdit→ViewHistoryCNikolay Nikolov (Presenting, annotating)BookmarksProfilesToolsWindowHelp(abl• Backend Chapter • 30 m leftmeet.google.com/gjc-ikxu-wxu?authuser=lukas.kovalik%40jiminny.com100% <478 • Fri 17 Apr 11:00:57Fri 17 Ape 11:00BacelrOuOC TODOC Debug crm-sync..Workers|Da0a000 L KelnelC Sentry- tSI- MergedJy 19666 hs import contacts and accounts batch job #11796LakyLak merged 28 commsaster from JY-19666-H5-inport-contacts-and-accounts-batch-jotl• Add HubSpot batch import jobs for contacts and companies (InportContactBatch, InportAccountßatch ) andrefactor deal import into shared InportßatchJobTrait with consistent logging, retry, and Redis requeue semantics.• Extend HubSpot service + traits to:• Implement inportContactBatchBylds /inportAccountBatchByIds and their batch helpers, includingprerercning assocasco accoums for contacts• Route HubSpot in Syncobjects to batch-sync companies/contacts and then deals.• Tighten webhook metrics and property-change handling to work with the new multi-object batch model, andexpand/adfust unit tests across jobs, batch processor, metrics service, property-change manager, sync strategy.La Learn+JY-19666 nove inport contacts and inport accounts to batch job & chan. =windsurf-bot bor commented on Mar 12The total size of the files in this PR is too large.X 9c6abbsL Al Chapten• Home@ Review( Rev Prophet|MilestonelDeytoomeSuccessfully merging this pull request may close theseNone yetNotificationsCustomize8, UnsubscribeYou're receiving notifications because you werementioned.eоge88 Lock conversationL Al BockmarxsNikolay NikolovLakyLak added 2 commits last month |o JY-19666 11x test• JY-19666 1ix code smells & add code coverage• ® LakyLak requested review from Vasil-Jiminny, nikolaybiaivanov and yalokin-jiminny last monthmeet.google.com is sharing your screen. Stop sharingX 998367cV 5441c7eLukas Kovalik11:00 AM | Backend Chapter......
|
NULL
|
-9071554000416549725
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEdit→ViewHistoryCNikolay Nikolov (Prese FirefoxFileEdit→ViewHistoryCNikolay Nikolov (Presenting, annotating)BookmarksProfilesToolsWindowHelp(abl• Backend Chapter • 30 m leftmeet.google.com/gjc-ikxu-wxu?authuser=lukas.kovalik%40jiminny.com100% <478 • Fri 17 Apr 11:00:57Fri 17 Ape 11:00BacelrOuOC TODOC Debug crm-sync..Workers|Da0a000 L KelnelC Sentry- tSI- MergedJy 19666 hs import contacts and accounts batch job #11796LakyLak merged 28 commsaster from JY-19666-H5-inport-contacts-and-accounts-batch-jotl• Add HubSpot batch import jobs for contacts and companies (InportContactBatch, InportAccountßatch ) andrefactor deal import into shared InportßatchJobTrait with consistent logging, retry, and Redis requeue semantics.• Extend HubSpot service + traits to:• Implement inportContactBatchBylds /inportAccountBatchByIds and their batch helpers, includingprerercning assocasco accoums for contacts• Route HubSpot in Syncobjects to batch-sync companies/contacts and then deals.• Tighten webhook metrics and property-change handling to work with the new multi-object batch model, andexpand/adfust unit tests across jobs, batch processor, metrics service, property-change manager, sync strategy.La Learn+JY-19666 nove inport contacts and inport accounts to batch job & chan. =windsurf-bot bor commented on Mar 12The total size of the files in this PR is too large.X 9c6abbsL Al Chapten• Home@ Review( Rev Prophet|MilestonelDeytoomeSuccessfully merging this pull request may close theseNone yetNotificationsCustomize8, UnsubscribeYou're receiving notifications because you werementioned.eоge88 Lock conversationL Al BockmarxsNikolay NikolovLakyLak added 2 commits last month |o JY-19666 11x test• JY-19666 1ix code smells & add code coverage• ® LakyLak requested review from Vasil-Jiminny, nikolaybiaivanov and yalokin-jiminny last monthmeet.google.com is sharing your screen. Stop sharingX 998367cV 5441c7eLukas Kovalik11:00 AM | Backend Chapter......
|
43293
|
|
72363
|
1763
|
26
|
2026-04-22T15:38:00.958923+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-22/1776 /Users/lukas/.screenpipe/data/data/2026-04-22/1776872280958_m2.jpg...
|
Firefox
|
Jiminny MCP Connector - Product - Confluence — Wor Jiminny MCP Connector - Product - Confluence — Work...
|
True
|
jiminny.atlassian.net/wiki/spaces/PROD/pages/37282 jiminny.atlassian.net/wiki/spaces/PROD/pages/3728244737/Jiminny+MCP+Connector...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
JY-9712 | change nudges schema by nikolaybiaivanov JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app
github.com
Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
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
[JY-20372] AI Reports > Empty page design and promotion - Jira
[JY-20372] AI Reports > Empty page design and promotion - Jira
Jiminny MCP Connector - Product - Confluence
Jiminny MCP Connector - Product - Confluence
Jiminny
Jiminny
Workers | Datadog
Workers | Datadog
Pull requests · jiminny/app
Pull requests · jiminny/app
[JY-20728] [HubSpot] Find the root cause of 429 hit and tweak API client rate limiter - Jira
[JY-20728] [HubSpot] Find the root cause of 429 hit and tweak API client rate limiter - Jira
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
Jiminny
Jiminny
CloudWatch | us-east-2
CloudWatch | us-east-2
Your 'Not enpough activities' report wasn't generated - [EMAIL] - Jiminny Mail
Your 'Not enpough activities' report wasn't generated - [EMAIL] - Jiminny Mail
Jiminny MCP Connector - Product - Confluence
Jiminny MCP Connector - Product - Confluence
Close tab
JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app
JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app
Close tab
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
Architecture overview - Model Context Protocol
Architecture overview - Model Context Protocol
Laravel MCP - AI tooling for Laravel, by the Laravel team | Laravel - The clean stack for Artisans and agents
Laravel MCP - AI tooling for Laravel, by the Laravel team | Laravel - The clean stack for Artisans and agents
Laravel MCP | Laravel 13.x - The clean stack for Artisans and agents
Laravel MCP | Laravel 13.x - The clean stack for Artisans and agents
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
Collapse sidebar Ctrl [
Collapse sidebar
Ctrl
[
Switch sites or apps
Switch sites or apps
Confluence
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
Notifications
Notifications
Help
Help
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Spaces
Spaces
Apps
Apps
Product
Product
More actions
More actions
Back to top
Back to top
Content
Content
Create Create
Create
Change view
Change view
Search by title
Results will update as you type.
Product Strategy 2025+ Change emoji
Product Strategy 2025+
Product Strategy 2025+
Competitive analysis Change emoji
Competitive analysis
Competitive analysis
Ways of working - Product processes Change emoji
Ways of working - Product processes
Ways of working - Product processes
Core Product Performance Metrics 📈
Core Product Performance Metrics
📈
Core Product Performance Metrics
Product Documentation Change emoji
Product Documentation
Product Documentation
Product Briefs Change emoji
Product Briefs
Product Briefs
Change emoji Activity Export
Activity Export
Change emoji Autologging activities
Autologging activities
Deal Insights - Multiple currencies
Deal Insights - Multiple currencies
Reinvent Themes & Topics (and ACS)
Reinvent Themes & Topics (and ACS)
Change emoji Billing Portal
Billing Portal
Change emoji Upload Video/Audio Recordings
Upload Video/Audio Recordings
White-Label Jiminny Instance
White-Label Jiminny Instance
Win/Loss Analysis for a Deal
Win/Loss Analysis for a Deal
Change emoji Hubspot app
Hubspot app
Change emoji Automatically hard delete data for churned customers
Automatically hard delete data for churned customers
Change emoji Ask Jiminny Anything on Call level
Ask Jiminny Anything on Call level
Change emoji Ask Jiminny for Open and Closed Deals
Ask Jiminny for Open and Closed Deals
Change emoji Ask Jiminny Anything on Deal level
Ask Jiminny Anything on Deal level
Change emoji Automatically record all calendar meetings
Automatically record all calendar meetings
Change emoji Product Tiering
Product Tiering
Change emoji Recording Consent
Recording Consent
Change emoji Automated CRM Filling
Automated CRM Filling
Change emoji Automated Exec Reports
Automated Exec Reports
Change emoji Auto-detect Activity Type
Auto-detect Activity Type
AI Signals & Alerts
AI Signals & Alerts
Change emoji AJA on Anything
AJA on Anything
Change emoji AI Call Scoring
AI Call Scoring
Jiminny MCP Connector
Jiminny MCP Connector
Desktop app to record a meetings without visible Notetaker
Desktop app to record a meetings without visible Notetaker
Feedback Change emoji
Feedback
Feedback
Research & User Feedback Change emoji
Research & User Feedback
Research & User Feedback
Create
Create
Jira , (opens new window)
Jira
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
More
More
Side Navigation Drag Handle
Breadcrumbs
Jiminny MCP Connector
Updated 10m ago
Updated 10m ago
Edit this content
Edit
Share, Open - Anyone in the space can edit
Share
Copy link
Copy link
More actions
More actions
Jiminny MCP Connector
Jiminny MCP Connector
Jiminny MCP Connector
By Galya Dimitrova
By Galya Dimitrova
Read time 12 min
12 min
Views 14
14
Add a reaction
Add a reaction
Epic
Link to Epik in Jira
Document status
DRAFT
Epic
Document status
Link to Epik in Jira
DRAFT
Change emoji Objective
Objective
Change emoji Objective
Enable customers to connect Jiminny data to external AI tools (Claude, OpenAI, Gemini) so it can be used as part of their broader knowledge base and workflows.
Position Jiminny as a
data layer for AI-driven revenue workflows
, not just a standalone product.
👤 Target user
👤 Target user
👤 Target user
Revenue teams using AI tools (Sales,
CS
, RevOps)
Companies already experimenting with Claude / OpenAI
Mid-market & Enterprise customers with multiple data sources (CRM, docs, CI tools, Support tools etc)
🤕 Pain point or problem
🤕
🤕
Pain point or problem
🤕 Pain point or problem
Jiminny data is locked in the platform
AI tools lack access to high-value conversation context
Customers must manually export/copy transcripts
No easy way to combine calls with CRM + docs + other info - using the Jiminny API is not suitable for non technical sales people
Peer pressure as almost all competitors have this
💥 Impact and benefits
💥
💥
Impact and benefits
💥 Impact and benefits
For customers
For customers
For customers
Better AI outputs using real customer conversations
Ability to build automated workflows across tools
For Jiminny
For Jiminny
For Jiminny
Increased product stickiness (embedded in workflows)
Competitive parity with Gong / Avoma etc.
💡 Solution ideas
💡
💡
Solution ideas
💡 Solution ideas
Build an
AI Knowledge Connector (
MCP
-based)
that:
Exposes Jiminny data (calls, transcripts, metadata) to AI tools
Provides
structured insights
(summaries, action items, scoring etc.)
Supports
deal-level context
Allows integration into customer-owned AI stacks (Claude + Zapier, etc.)
Change emoji Success metrics
Success metrics
Change emoji Success metrics
List the key goals and how you will measure success. Include both qualitative and quantitative metrics (e.g., user adoption, retention rate, revenue targets).
Goal
No sort applied to the column
Metric
No sort applied to the column
Increase stickiness by ensuring Jiminny is embedded in the customer’s workflows
% of customers connecting external AI tools
Number of
MCP
/API calls per customer - and which ones are being used the most
Decrease the manual copying of transcripts
decrease by X %
Goal
No sort applied to the column
Increase stickiness by ensuring Jiminny is embedded in the customer’s workflows
Decrease the manual copying of transcripts
Metric
No sort applied to the column
% of customers connecting external AI tools
Number of
MCP
/API calls per customer - and which ones are being used the most
decrease by X %
Change emoji User interaction and design
User interaction and design
Change emoji User interaction and design
Simple setup flow:
Generate API key / connect
MCP
Clear instructions for Claude / Zapier setup
Provide:
Example prompts for Claude
Pre-built workflow templates for Zapier
Clear documentation - with use cases
👉 Focus:
usable by non-technical users
Change emoji Detailed Requirements
Detailed Requirements
Change emoji Detailed Requirements
List the functional and non-functional requirements. Prioritize them (e.g., High, Medium, Low) and add notes for clarification.
Requirement
No sort applied to the column
Importance
No sort applied to the column
Notes
No sort applied to the column...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app","depth":4,"bounds":{"left":0.3648604,"top":0.49481246,"width":0.07263963,"height":0.032322425},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"github.com","depth":4,"bounds":{"left":0.3648604,"top":0.51636076,"width":0.019614361,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira","depth":4,"bounds":{"left":0.28307846,"top":0.0518755,"width":0.07596409,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"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.28125,"top":0.09497207,"width":0.07962101,"height":0.032721467},"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.2945479,"top":0.10614525,"width":0.4644282,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20372] AI Reports > Empty page design and promotion - Jira","depth":4,"bounds":{"left":0.28125,"top":0.12769353,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20372] AI Reports > Empty page design and promotion - Jira","depth":5,"bounds":{"left":0.2945479,"top":0.13886672,"width":0.11319814,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny MCP Connector - Product - Confluence","depth":4,"bounds":{"left":0.28125,"top":0.16041501,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny MCP Connector - Product - Confluence","depth":5,"bounds":{"left":0.2945479,"top":0.17158818,"width":0.08294548,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.28125,"top":0.19313647,"width":0.07962101,"height":0.032721467},"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.2945479,"top":0.20430966,"width":0.013131649,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"bounds":{"left":0.28125,"top":0.22585794,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Workers | Datadog","depth":5,"bounds":{"left":0.2945479,"top":0.23703113,"width":0.032081116,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pull requests · jiminny/app","depth":4,"bounds":{"left":0.28125,"top":0.2585794,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests · jiminny/app","depth":5,"bounds":{"left":0.2945479,"top":0.2697526,"width":0.04537899,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20728] [HubSpot] Find the root cause of 429 hit and tweak API client rate limiter - Jira","depth":4,"bounds":{"left":0.28125,"top":0.29130086,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20728] [HubSpot] Find the root cause of 429 hit and tweak API client rate limiter - Jira","depth":5,"bounds":{"left":0.2945479,"top":0.30247405,"width":0.15791224,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app","depth":4,"bounds":{"left":0.28125,"top":0.32402235,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app","depth":5,"bounds":{"left":0.2945479,"top":0.33519554,"width":0.16555852,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.28125,"top":0.3567438,"width":0.07962101,"height":0.032721467},"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.2945479,"top":0.367917,"width":0.013131649,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | us-east-2","depth":4,"bounds":{"left":0.28125,"top":0.38946527,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | us-east-2","depth":5,"bounds":{"left":0.2945479,"top":0.40063846,"width":0.041223403,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Your 'Not enpough activities' report wasn't generated - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.28125,"top":0.42218676,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Your 'Not enpough activities' report wasn't generated - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.2945479,"top":0.43335995,"width":0.16821809,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny MCP Connector - Product - Confluence","depth":4,"bounds":{"left":0.28125,"top":0.45490822,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Jiminny MCP Connector - Product - Confluence","depth":5,"bounds":{"left":0.2945479,"top":0.4660814,"width":0.08294548,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.34857047,"top":0.46209097,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app","depth":4,"bounds":{"left":0.28125,"top":0.48762968,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app","depth":5,"bounds":{"left":0.2945479,"top":0.49880287,"width":0.15475398,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.34857047,"top":0.49481246,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app","depth":4,"bounds":{"left":0.28125,"top":0.5203512,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app","depth":5,"bounds":{"left":0.2945479,"top":0.53152436,"width":0.16555852,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Architecture overview - Model Context Protocol","depth":4,"bounds":{"left":0.28125,"top":0.55307263,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Architecture overview - Model Context Protocol","depth":5,"bounds":{"left":0.2945479,"top":0.5642458,"width":0.08277926,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Laravel MCP - AI tooling for Laravel, by the Laravel team | Laravel - The clean stack for Artisans and agents","depth":4,"bounds":{"left":0.28125,"top":0.5857941,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Laravel MCP - AI tooling for Laravel, by the Laravel team | Laravel - The clean stack for Artisans and agents","depth":5,"bounds":{"left":0.2945479,"top":0.5969673,"width":0.18583776,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Laravel MCP | Laravel 13.x - The clean stack for Artisans and agents","depth":4,"bounds":{"left":0.28125,"top":0.61851555,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Laravel MCP | Laravel 13.x - The clean stack for Artisans and agents","depth":5,"bounds":{"left":0.2945479,"top":0.62968874,"width":0.1178524,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.2840758,"top":0.6528332,"width":0.07413564,"height":0.025538707},"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.2840758,"top":0.97007185,"width":0.010638298,"height":0.025538707},"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.29504654,"top":0.97007185,"width":0.010638298,"height":0.025538707},"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.30618352,"top":0.97007185,"width":0.010638298,"height":0.025538707},"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.31732047,"top":0.97007185,"width":0.010638298,"height":0.025538707},"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.32845744,"top":0.97007185,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to:","depth":10,"bounds":{"left":0.3715093,"top":0.07861133,"width":0.016954787,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Top Bar","depth":11,"bounds":{"left":0.3715093,"top":0.097765364,"width":0.016954787,"height":0.01396648},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Top Bar","depth":12,"bounds":{"left":0.3715093,"top":0.097765364,"width":0.016954787,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sidebar","depth":11,"bounds":{"left":0.3715093,"top":0.11691939,"width":0.016954787,"height":0.01396648},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sidebar","depth":12,"bounds":{"left":0.3715093,"top":0.11691939,"width":0.016954787,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Main Content","depth":11,"bounds":{"left":0.3715093,"top":0.13607343,"width":0.029421542,"height":0.01396648},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Main Content","depth":12,"bounds":{"left":0.3715093,"top":0.13607343,"width":0.029421542,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse sidebar Ctrl [","depth":10,"bounds":{"left":0.3648604,"top":0.057861134,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse sidebar","depth":12,"bounds":{"left":0.3700133,"top":0.066640064,"width":0.03673537,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Ctrl","depth":13,"bounds":{"left":0.41073802,"top":0.066640064,"width":0.007978723,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[","depth":13,"bounds":{"left":0.4253657,"top":0.066640064,"width":0.0016622341,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Switch sites or apps","depth":12,"bounds":{"left":0.37682846,"top":0.057861134,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Switch sites or apps","depth":14,"bounds":{"left":0.38198137,"top":0.06344773,"width":0.044215426,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Confluence","depth":10,"bounds":{"left":0.39012632,"top":0.057861134,"width":0.029421542,"height":0.025538707},"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.545379,"top":0.06264964,"width":0.24268617,"height":0.015961692},"help_text":"","placeholder":"Search Confluence, Jira, Google Drive and other apps","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.79637635,"top":0.057861134,"width":0.030086435,"height":0.025538707},"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.80767953,"top":0.06384677,"width":0.014793883,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Rovo Ask Rovo","depth":13,"bounds":{"left":0.92420214,"top":0.057861134,"width":0.035904255,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Rovo","depth":15,"bounds":{"left":0.93550533,"top":0.06384677,"width":0.020611702,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Notifications","depth":13,"bounds":{"left":0.96143615,"top":0.057861134,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Notifications","depth":15,"bounds":{"left":0.9665891,"top":0.06344773,"width":0.027759308,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Help","depth":13,"bounds":{"left":0.9734042,"top":0.057861134,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Help","depth":15,"bounds":{"left":0.97855717,"top":0.06344773,"width":0.010139627,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"lukas.kovalik@jiminny.com","depth":13,"bounds":{"left":0.98537236,"top":0.057861134,"width":0.010638298,"height":0.025538707},"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":15,"bounds":{"left":0.99052525,"top":0.06344773,"width":0.009474754,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"For you","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"For you","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Recent","depth":13,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Recent","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Starred","depth":13,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Starred","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Spaces","depth":13,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Spaces","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Apps","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Apps","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Product","depth":13,"bounds":{"left":0.3648604,"top":0.09976058,"width":0.15392287,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Product","depth":16,"bounds":{"left":0.37549868,"top":0.10574621,"width":0.017453458,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions","depth":14,"bounds":{"left":0.50947475,"top":0.10295291,"width":0.007978723,"height":0.01915403},"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","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Back to top","depth":11,"bounds":{"left":0.42104387,"top":0.1396648,"width":0.04155585,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Back to top","depth":13,"bounds":{"left":0.43384308,"top":0.14644852,"width":0.021276595,"height":0.011971269},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Content","depth":12,"bounds":{"left":0.3648604,"top":0.0,"width":0.15392287,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"Content","depth":15,"bounds":{"left":0.37549868,"top":0.0,"width":0.01761968,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Create Create","depth":14,"bounds":{"left":0.501496,"top":0.0,"width":0.007978723,"height":0.01915403},"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":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Change view","depth":13,"bounds":{"left":0.50947475,"top":0.0,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Change view","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXTextField","text":"Search by title","depth":15,"bounds":{"left":0.37583113,"top":0.005586592,"width":0.14261968,"height":0.022346368},"role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Results will update as you type.","depth":15,"bounds":{"left":0.36751994,"top":0.00518755,"width":0.06931516,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Product Strategy 2025+ Change emoji","depth":16,"bounds":{"left":0.36884972,"top":0.035913806,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Product Strategy 2025+","depth":17,"bounds":{"left":0.37017953,"top":0.039106146,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Product Strategy 2025+","depth":19,"bounds":{"left":0.38813165,"top":0.041899443,"width":0.053523935,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Competitive analysis Change emoji","depth":16,"bounds":{"left":0.36884972,"top":0.061452515,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Competitive analysis","depth":17,"bounds":{"left":0.37017953,"top":0.06464485,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Competitive analysis","depth":19,"bounds":{"left":0.38813165,"top":0.06743815,"width":0.046210106,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Ways of working - Product processes Change emoji","depth":16,"bounds":{"left":0.36884972,"top":0.08699122,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Ways of working - Product processes","depth":17,"bounds":{"left":0.37017953,"top":0.090183556,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Ways of working - Product processes","depth":19,"bounds":{"left":0.38813165,"top":0.09297685,"width":0.08361037,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Core Product Performance Metrics 📈","depth":16,"bounds":{"left":0.36884972,"top":0.112529926,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Core Product Performance Metrics","depth":17,"bounds":{"left":0.37017953,"top":0.11572227,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"📈","depth":18,"bounds":{"left":0.3801529,"top":0.118515566,"width":0.004654255,"height":0.014764565},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Core Product Performance Metrics","depth":19,"bounds":{"left":0.38813165,"top":0.118515566,"width":0.0774601,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Product Documentation Change emoji","depth":16,"bounds":{"left":0.36884972,"top":0.13806863,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Product Documentation","depth":17,"bounds":{"left":0.37017953,"top":0.14126097,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Product Documentation","depth":19,"bounds":{"left":0.38813165,"top":0.14405426,"width":0.05269282,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Product Briefs Change emoji","depth":16,"bounds":{"left":0.36884972,"top":0.16360734,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Product Briefs","depth":17,"bounds":{"left":0.37017953,"top":0.16679968,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"Product Briefs","depth":19,"bounds":{"left":0.38813165,"top":0.16959298,"width":0.03174867,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Activity Export","depth":18,"bounds":{"left":0.37416887,"top":0.18914606,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Activity Export","depth":21,"bounds":{"left":0.3934508,"top":0.19513169,"width":0.03274601,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Autologging activities","depth":18,"bounds":{"left":0.37416887,"top":0.21468475,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Autologging activities","depth":21,"bounds":{"left":0.3934508,"top":0.22067039,"width":0.048204787,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Deal Insights - Multiple currencies","depth":18,"bounds":{"left":0.37416887,"top":0.24022347,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Deal Insights - Multiple currencies","depth":21,"bounds":{"left":0.3934508,"top":0.2462091,"width":0.076961435,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Reinvent Themes & Topics (and ACS)","depth":18,"bounds":{"left":0.37416887,"top":0.26576218,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Reinvent Themes & Topics (and ACS)","depth":21,"bounds":{"left":0.3934508,"top":0.2717478,"width":0.082446806,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Billing Portal","depth":18,"bounds":{"left":0.37416887,"top":0.29130086,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Billing Portal","depth":21,"bounds":{"left":0.3934508,"top":0.2972865,"width":0.027759308,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Upload Video/Audio Recordings","depth":18,"bounds":{"left":0.37416887,"top":0.31683958,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Upload Video/Audio Recordings","depth":21,"bounds":{"left":0.3934508,"top":0.32282522,"width":0.0709774,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"White-Label Jiminny Instance","depth":18,"bounds":{"left":0.37416887,"top":0.3423783,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"White-Label Jiminny Instance","depth":21,"bounds":{"left":0.3934508,"top":0.34836394,"width":0.066821806,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Win/Loss Analysis for a Deal","depth":18,"bounds":{"left":0.37416887,"top":0.367917,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Win/Loss Analysis for a Deal","depth":21,"bounds":{"left":0.3934508,"top":0.37390262,"width":0.06349734,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Hubspot app","depth":18,"bounds":{"left":0.37416887,"top":0.3934557,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Hubspot app","depth":21,"bounds":{"left":0.3934508,"top":0.39944133,"width":0.028590426,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Automatically hard delete data for churned customers","depth":18,"bounds":{"left":0.37416887,"top":0.41899443,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Automatically hard delete data for churned customers","depth":21,"bounds":{"left":0.3934508,"top":0.42498004,"width":0.11968085,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Ask Jiminny Anything on Call level","depth":18,"bounds":{"left":0.37416887,"top":0.4445331,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Jiminny Anything on Call level","depth":21,"bounds":{"left":0.3934508,"top":0.45051876,"width":0.076296546,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Ask Jiminny for Open and Closed Deals","depth":18,"bounds":{"left":0.37416887,"top":0.47007182,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Jiminny for Open and Closed Deals","depth":21,"bounds":{"left":0.3934508,"top":0.47605747,"width":0.087765954,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Ask Jiminny Anything on Deal level","depth":18,"bounds":{"left":0.37416887,"top":0.49561054,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Jiminny Anything on Deal level","depth":21,"bounds":{"left":0.3934508,"top":0.50159615,"width":0.077792555,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Automatically record all calendar meetings","depth":18,"bounds":{"left":0.37416887,"top":0.5211492,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Automatically record all calendar meetings","depth":21,"bounds":{"left":0.3934508,"top":0.5271349,"width":0.094913565,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Product Tiering","depth":18,"bounds":{"left":0.37416887,"top":0.54668796,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Product Tiering","depth":21,"bounds":{"left":0.3934508,"top":0.5526736,"width":0.034408245,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Recording Consent","depth":18,"bounds":{"left":0.37416887,"top":0.57222664,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Recording Consent","depth":21,"bounds":{"left":0.3934508,"top":0.57821226,"width":0.04255319,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Automated CRM Filling","depth":18,"bounds":{"left":0.37416887,"top":0.5977654,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Automated CRM Filling","depth":21,"bounds":{"left":0.3934508,"top":0.603751,"width":0.05069814,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Automated Exec Reports","depth":18,"bounds":{"left":0.37416887,"top":0.62330407,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Automated Exec Reports","depth":21,"bounds":{"left":0.3934508,"top":0.6292897,"width":0.054853722,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji Auto-detect Activity Type","depth":18,"bounds":{"left":0.37416887,"top":0.64884275,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Auto-detect Activity Type","depth":21,"bounds":{"left":0.3934508,"top":0.6548284,"width":0.05718085,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"AI Signals & Alerts","depth":18,"bounds":{"left":0.37416887,"top":0.6743815,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"AI Signals & Alerts","depth":21,"bounds":{"left":0.3934508,"top":0.6803671,"width":0.04155585,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji AJA on Anything","depth":18,"bounds":{"left":0.37416887,"top":0.6999202,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"AJA on Anything","depth":21,"bounds":{"left":0.3934508,"top":0.70590585,"width":0.03723404,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Change emoji AI Call Scoring","depth":18,"bounds":{"left":0.37416887,"top":0.7254589,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"AI Call Scoring","depth":21,"bounds":{"left":0.3934508,"top":0.73144454,"width":0.03357713,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny MCP Connector","depth":18,"bounds":{"left":0.37416887,"top":0.7509976,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny MCP Connector","depth":21,"bounds":{"left":0.3934508,"top":0.7569832,"width":0.053856384,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Desktop app to record a meetings without visible Notetaker","depth":18,"bounds":{"left":0.37416887,"top":0.7765363,"width":0.14461437,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Desktop app to record a meetings without visible Notetaker","depth":21,"bounds":{"left":0.3934508,"top":0.78252196,"width":0.13164894,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Feedback Change emoji","depth":16,"bounds":{"left":0.36884972,"top":0.802075,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Feedback","depth":17,"bounds":{"left":0.37017953,"top":0.80526733,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Feedback","depth":19,"bounds":{"left":0.38813165,"top":0.80806065,"width":0.021775266,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Research & User Feedback Change emoji","depth":16,"bounds":{"left":0.36884972,"top":0.8276137,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Research & User Feedback","depth":17,"bounds":{"left":0.37017953,"top":0.8308061,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Research & User Feedback","depth":19,"bounds":{"left":0.38813165,"top":0.8335994,"width":0.059840426,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create","depth":16,"bounds":{"left":0.36884972,"top":0.85315245,"width":0.14993352,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create","depth":19,"bounds":{"left":0.37948802,"top":0.8591381,"width":0.014793883,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jira , (opens new window)","depth":14,"bounds":{"left":0.3648604,"top":0.9042298,"width":0.15392287,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jira","depth":18,"bounds":{"left":0.37549868,"top":0.9102155,"width":0.00831117,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":16,"bounds":{"left":0.3648604,"top":0.91779727,"width":0.04837101,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Teams , (opens new window)","depth":14,"bounds":{"left":0.3648604,"top":0.92976856,"width":0.15392287,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Teams","depth":18,"bounds":{"left":0.37549868,"top":0.9357542,"width":0.014793883,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":16,"bounds":{"left":0.3648604,"top":0.943336,"width":0.04837101,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"open menu","depth":15,"bounds":{"left":0.50748,"top":0.93296087,"width":0.0039893617,"height":0.01915403},"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":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More","depth":13,"bounds":{"left":0.3648604,"top":0.9648843,"width":0.15392287,"height":0.025538707},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.37549868,"top":0.9708699,"width":0.011469414,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Side Navigation Drag Handle","depth":14,"bounds":{"left":0.5746343,"top":0.0981644,"width":0.0631649,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Breadcrumbs","depth":15,"bounds":{"left":0.5270944,"top":0.09936153,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jiminny MCP Connector","depth":17,"bounds":{"left":0.548371,"top":0.105347164,"width":0.0546875,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Updated 10m ago","depth":16,"bounds":{"left":0.8372673,"top":0.105347164,"width":0.03873005,"height":0.01396648},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Updated 10m ago","depth":17,"bounds":{"left":0.8372673,"top":0.105347164,"width":0.03873005,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Edit this content","depth":16,"bounds":{"left":0.9172208,"top":0.09936153,"width":0.023603724,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Edit","depth":18,"bounds":{"left":0.92852396,"top":0.105347164,"width":0.00831117,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Share, Open - Anyone in the space can edit","depth":16,"bounds":{"left":0.9428192,"top":0.09936153,"width":0.02825798,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Share","depth":18,"bounds":{"left":0.95412236,"top":0.105347164,"width":0.012965426,"height":0.01396648},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy link","depth":16,"bounds":{"left":0.97074467,"top":0.09936153,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Copy link","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions","depth":15,"bounds":{"left":0.98337764,"top":0.09936153,"width":0.010638298,"height":0.025538707},"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","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Jiminny MCP Connector","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Jiminny MCP Connector","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Jiminny MCP Connector","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"By Galya Dimitrova","depth":13,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"By Galya Dimitrova","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Read time 12 min","depth":13,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12 min","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Views 14","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"14","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add a reaction","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Add a reaction","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Epic","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Link to Epik in Jira","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Document status","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DRAFT","depth":26,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Epic","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Document status","depth":23,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Link to Epik in Jira","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DRAFT","depth":26,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Change emoji Objective","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Objective","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Change emoji Objective","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Enable customers to connect Jiminny data to external AI tools (Claude, OpenAI, Gemini) so it can be used as part of their broader knowledge base and workflows.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Position Jiminny as a","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"data layer for AI-driven revenue workflows","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", not just a standalone product.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"👤 Target user","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"👤 Target user","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"👤 Target user","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Revenue teams using AI tools (Sales,","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"CS","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", RevOps)","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Companies already experimenting with Claude / OpenAI","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Mid-market & Enterprise customers with multiple data sources (CRM, docs, CI tools, Support tools etc)","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"🤕 Pain point or problem","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"🤕","depth":16,"help_text":"https://emojipedia.org/face-with-head-bandage/","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"🤕","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Pain point or problem","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"🤕 Pain point or problem","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny data is locked in the platform","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"AI tools lack access to high-value conversation context","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Customers must manually export/copy transcripts","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"No easy way to combine calls with CRM + docs + other info - using the Jiminny API is not suitable for non technical sales people","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Peer pressure as almost all competitors have this","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"💥 Impact and benefits","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"💥","depth":16,"help_text":"https://emojiterra.com/collision/","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"💥","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Impact and benefits","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"💥 Impact and benefits","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"For customers","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"For customers","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"For customers","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Better AI outputs using real customer conversations","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Ability to build automated workflows across tools","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"For Jiminny","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"For Jiminny","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"For Jiminny","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Increased product stickiness (embedded in workflows)","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Competitive parity with Gong / Avoma etc.","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"💡 Solution ideas","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"💡","depth":16,"help_text":"https://emojipedia.org/light-bulb/","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"💡","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Solution ideas","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"💡 Solution ideas","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Build an","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"AI Knowledge Connector (","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MCP","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-based)","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"that:","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Exposes Jiminny data (calls, transcripts, metadata) to AI tools","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Provides","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"structured insights","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(summaries, action items, scoring etc.)","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Supports","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"deal-level context","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Allows integration into customer-owned AI stacks (Claude + Zapier, etc.)","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Change emoji Success metrics","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Success metrics","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Change emoji Success metrics","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List the key goals and how you will measure success. Include both qualitative and quantitative metrics (e.g., user adoption, retention rate, revenue targets).","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Goal","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"No sort applied to the column","depth":20,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Metric","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"No sort applied to the column","depth":20,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Increase stickiness by ensuring Jiminny is embedded in the customer’s workflows","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"% of customers connecting external AI tools","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Number of","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MCP","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/API calls per customer - and which ones are being used the most","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Decrease the manual copying of transcripts","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"decrease by X %","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Goal","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"No sort applied to the column","depth":20,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Increase stickiness by ensuring Jiminny is embedded in the customer’s workflows","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Decrease the manual copying of transcripts","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Metric","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"No sort applied to the column","depth":20,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"% of customers connecting external AI tools","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Number of","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MCP","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/API calls per customer - and which ones are being used the most","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"decrease by X %","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Change emoji User interaction and design","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"User interaction and design","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Change emoji User interaction and design","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Simple setup flow:","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Generate API key / connect","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MCP","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Clear instructions for Claude / Zapier setup","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Provide:","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Example prompts for Claude","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Pre-built workflow templates for Zapier","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Clear documentation - with use cases","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"👉 Focus:","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"usable by non-technical users","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Change emoji Detailed Requirements","depth":15,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Detailed Requirements","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Change emoji Detailed Requirements","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List the functional and non-functional requirements. Prioritize them (e.g., High, Medium, Low) and add notes for clarification.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Requirement","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"No sort applied to the column","depth":20,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Importance","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"No sort applied to the column","depth":20,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Notes","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"No sort applied to the column","depth":20,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-9071123471562958068
|
-3467846564477929803
|
click
|
accessibility
|
NULL
|
JY-9712 | change nudges schema by nikolaybiaivanov JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app
github.com
Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
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
[JY-20372] AI Reports > Empty page design and promotion - Jira
[JY-20372] AI Reports > Empty page design and promotion - Jira
Jiminny MCP Connector - Product - Confluence
Jiminny MCP Connector - Product - Confluence
Jiminny
Jiminny
Workers | Datadog
Workers | Datadog
Pull requests · jiminny/app
Pull requests · jiminny/app
[JY-20728] [HubSpot] Find the root cause of 429 hit and tweak API client rate limiter - Jira
[JY-20728] [HubSpot] Find the root cause of 429 hit and tweak API client rate limiter - Jira
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
Jiminny
Jiminny
CloudWatch | us-east-2
CloudWatch | us-east-2
Your 'Not enpough activities' report wasn't generated - [EMAIL] - Jiminny Mail
Your 'Not enpough activities' report wasn't generated - [EMAIL] - Jiminny Mail
Jiminny MCP Connector - Product - Confluence
Jiminny MCP Connector - Product - Confluence
Close tab
JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app
JY-9712 | change nudges schema by nikolaybiaivanov · Pull Request #11983 · jiminny/app
Close tab
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
JY-9712 | Nuges to expire after one year by nikolaybiaivanov · Pull Request #11981 · jiminny/app
Architecture overview - Model Context Protocol
Architecture overview - Model Context Protocol
Laravel MCP - AI tooling for Laravel, by the Laravel team | Laravel - The clean stack for Artisans and agents
Laravel MCP - AI tooling for Laravel, by the Laravel team | Laravel - The clean stack for Artisans and agents
Laravel MCP | Laravel 13.x - The clean stack for Artisans and agents
Laravel MCP | Laravel 13.x - The clean stack for Artisans and agents
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
Collapse sidebar Ctrl [
Collapse sidebar
Ctrl
[
Switch sites or apps
Switch sites or apps
Confluence
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
Notifications
Notifications
Help
Help
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Spaces
Spaces
Apps
Apps
Product
Product
More actions
More actions
Back to top
Back to top
Content
Content
Create Create
Create
Change view
Change view
Search by title
Results will update as you type.
Product Strategy 2025+ Change emoji
Product Strategy 2025+
Product Strategy 2025+
Competitive analysis Change emoji
Competitive analysis
Competitive analysis
Ways of working - Product processes Change emoji
Ways of working - Product processes
Ways of working - Product processes
Core Product Performance Metrics 📈
Core Product Performance Metrics
📈
Core Product Performance Metrics
Product Documentation Change emoji
Product Documentation
Product Documentation
Product Briefs Change emoji
Product Briefs
Product Briefs
Change emoji Activity Export
Activity Export
Change emoji Autologging activities
Autologging activities
Deal Insights - Multiple currencies
Deal Insights - Multiple currencies
Reinvent Themes & Topics (and ACS)
Reinvent Themes & Topics (and ACS)
Change emoji Billing Portal
Billing Portal
Change emoji Upload Video/Audio Recordings
Upload Video/Audio Recordings
White-Label Jiminny Instance
White-Label Jiminny Instance
Win/Loss Analysis for a Deal
Win/Loss Analysis for a Deal
Change emoji Hubspot app
Hubspot app
Change emoji Automatically hard delete data for churned customers
Automatically hard delete data for churned customers
Change emoji Ask Jiminny Anything on Call level
Ask Jiminny Anything on Call level
Change emoji Ask Jiminny for Open and Closed Deals
Ask Jiminny for Open and Closed Deals
Change emoji Ask Jiminny Anything on Deal level
Ask Jiminny Anything on Deal level
Change emoji Automatically record all calendar meetings
Automatically record all calendar meetings
Change emoji Product Tiering
Product Tiering
Change emoji Recording Consent
Recording Consent
Change emoji Automated CRM Filling
Automated CRM Filling
Change emoji Automated Exec Reports
Automated Exec Reports
Change emoji Auto-detect Activity Type
Auto-detect Activity Type
AI Signals & Alerts
AI Signals & Alerts
Change emoji AJA on Anything
AJA on Anything
Change emoji AI Call Scoring
AI Call Scoring
Jiminny MCP Connector
Jiminny MCP Connector
Desktop app to record a meetings without visible Notetaker
Desktop app to record a meetings without visible Notetaker
Feedback Change emoji
Feedback
Feedback
Research & User Feedback Change emoji
Research & User Feedback
Research & User Feedback
Create
Create
Jira , (opens new window)
Jira
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
More
More
Side Navigation Drag Handle
Breadcrumbs
Jiminny MCP Connector
Updated 10m ago
Updated 10m ago
Edit this content
Edit
Share, Open - Anyone in the space can edit
Share
Copy link
Copy link
More actions
More actions
Jiminny MCP Connector
Jiminny MCP Connector
Jiminny MCP Connector
By Galya Dimitrova
By Galya Dimitrova
Read time 12 min
12 min
Views 14
14
Add a reaction
Add a reaction
Epic
Link to Epik in Jira
Document status
DRAFT
Epic
Document status
Link to Epik in Jira
DRAFT
Change emoji Objective
Objective
Change emoji Objective
Enable customers to connect Jiminny data to external AI tools (Claude, OpenAI, Gemini) so it can be used as part of their broader knowledge base and workflows.
Position Jiminny as a
data layer for AI-driven revenue workflows
, not just a standalone product.
👤 Target user
👤 Target user
👤 Target user
Revenue teams using AI tools (Sales,
CS
, RevOps)
Companies already experimenting with Claude / OpenAI
Mid-market & Enterprise customers with multiple data sources (CRM, docs, CI tools, Support tools etc)
🤕 Pain point or problem
🤕
🤕
Pain point or problem
🤕 Pain point or problem
Jiminny data is locked in the platform
AI tools lack access to high-value conversation context
Customers must manually export/copy transcripts
No easy way to combine calls with CRM + docs + other info - using the Jiminny API is not suitable for non technical sales people
Peer pressure as almost all competitors have this
💥 Impact and benefits
💥
💥
Impact and benefits
💥 Impact and benefits
For customers
For customers
For customers
Better AI outputs using real customer conversations
Ability to build automated workflows across tools
For Jiminny
For Jiminny
For Jiminny
Increased product stickiness (embedded in workflows)
Competitive parity with Gong / Avoma etc.
💡 Solution ideas
💡
💡
Solution ideas
💡 Solution ideas
Build an
AI Knowledge Connector (
MCP
-based)
that:
Exposes Jiminny data (calls, transcripts, metadata) to AI tools
Provides
structured insights
(summaries, action items, scoring etc.)
Supports
deal-level context
Allows integration into customer-owned AI stacks (Claude + Zapier, etc.)
Change emoji Success metrics
Success metrics
Change emoji Success metrics
List the key goals and how you will measure success. Include both qualitative and quantitative metrics (e.g., user adoption, retention rate, revenue targets).
Goal
No sort applied to the column
Metric
No sort applied to the column
Increase stickiness by ensuring Jiminny is embedded in the customer’s workflows
% of customers connecting external AI tools
Number of
MCP
/API calls per customer - and which ones are being used the most
Decrease the manual copying of transcripts
decrease by X %
Goal
No sort applied to the column
Increase stickiness by ensuring Jiminny is embedded in the customer’s workflows
Decrease the manual copying of transcripts
Metric
No sort applied to the column
% of customers connecting external AI tools
Number of
MCP
/API calls per customer - and which ones are being used the most
decrease by X %
Change emoji User interaction and design
User interaction and design
Change emoji User interaction and design
Simple setup flow:
Generate API key / connect
MCP
Clear instructions for Claude / Zapier setup
Provide:
Example prompts for Claude
Pre-built workflow templates for Zapier
Clear documentation - with use cases
👉 Focus:
usable by non-technical users
Change emoji Detailed Requirements
Detailed Requirements
Change emoji Detailed Requirements
List the functional and non-functional requirements. Prioritize them (e.g., High, Medium, Low) and add notes for clarification.
Requirement
No sort applied to the column
Importance
No sort applied to the column
Notes
No sort applied to the column...
|
72361
|
|
15767
|
354
|
31
|
2026-04-14T14:59:43.013633+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-14/1776 /Users/lukas/.screenpipe/data/data/2026-04-14/1776178783013_m2.jpg...
|
Boosteroid
|
Boosteroid
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
1751002005/10Dark Age-House Built----Goat Found--- 1751002005/10Dark Age-House Built----Goat Found---Villager Created--Game Paused (P)Right-click to herd this animal to your TownCenter or Mill for food.Zhu Di: 206/2068 Vortigern: 188/1886 John the Blind: 188/1885 Urus Khan: 188/1884 Afonso de Albuquerque: 188/1883 Anawrahta: 188/1887 Humayun: 184/1841 kovaliklukas: 178/178...
|
NULL
|
-9071102684092503608
|
NULL
|
visual_change
|
ocr
|
NULL
|
1751002005/10Dark Age-House Built----Goat Found--- 1751002005/10Dark Age-House Built----Goat Found---Villager Created--Game Paused (P)Right-click to herd this animal to your TownCenter or Mill for food.Zhu Di: 206/2068 Vortigern: 188/1886 John the Blind: 188/1885 Urus Khan: 188/1884 Afonso de Albuquerque: 188/1883 Anawrahta: 188/1887 Humayun: 184/1841 kovaliklukas: 178/178...
|
15765
|
|
77097
|
1937
|
39
|
2026-04-24T08:54:23.179436+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-24/1777 /Users/lukas/.screenpipe/data/data/2026-04-24/1777020863179_m1.jpg...
|
PhpStorm
|
faVsco.js – .env.staging
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20738-debug-AJ-trackin Project: faVsco.js, menu
JY-20738-debug-AJ-tracking-UP, menu
Start Listening for PHP Debug Connections
ReportControllerTest
Run 'ReportControllerTest'
Debug 'ReportControllerTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
USERPI
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
APP_ENV=staging
[ENV_SECRET]
APP_DEBUG=false
LOG_CHANNEL=errorlog
LOG_LEVEL=info
APP_URL=https://app.staging.jiminny.com
AWS_DEFAULT_REGION=us-east-2
APP_REGION=us-east-2
APP_DEPLOY_REGION=us
SECURITY_HEADER_HSTS_ENABLE=true
CONTENT_SECURITY_POLICY_REPORT_URI=https://jmny.report-uri.com/r/d/csp/reportOnly
SECURITY_HEADER_CUSTOM_CSP=null
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=jiminny
DB_USERNAME=root
[ENV_SECRET]
CASHIER_MODEL=Jiminny\Models\User
BROADCAST_DRIVER=pusher
CACHE_DRIVER=redis
CACHE_PREFIX=jmny:
SESSION_DRIVER=redis
QUEUE_CONNECTION=sqs
GITHUB_TOKEN=null
REDIS_CLIENT=phpredis
REDIS_HOST=REDIS_ENDPOINT
[ENV_SECRET]
REDIS_PORT=6379
REDIS_PREFIX=jmny_database_
SENTRY_DSN=https://3943fc8002e149d5afa2d7ae983efd7d:[EMAIL]/82419
SENTRY_DSN_CONFERENCE=https://b0caaeacb6b9448fb1b51f7244c102a4:[EMAIL]/298914
SENTRY_DSN_FRONT_END=https://[EMAIL]/5627310
LOGROCKET_CONFERENCE_ID=ponxaf/conference-staging
LOGROCKET_APP_ID=ponxaf/platform-staging
MAIL_MAILER=ses
MAIL_HOST=email-smtp.us-east-1.amazonaws.com
MAIL_PORT=2525
MAIL_USERNAME=null
[ENV_SECRET]
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=[EMAIL]
MAIL_FROM_NAME="The Jiminny Team"
MAIL_DOMAIN=staging.jiminny.com
[ENV_SECRET]
TUNNEL_HOST=https://team:[EMAIL]
OUTLOOK_URL=https://outlook.staging.jiminny.com
PUSHER_APP_ID=
[ENV_SECRET]
PUSHER_APP_CLUSTER=mt1
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
STRIPE_MODEL=Jiminny\Models\Team
[ENV_SECRET]
[ENV_SECRET]
CASHIER_ENV=testing
PLANHAT_ENABLED=
PLANHAT_TENANT_UUID=
PLANHAT_JIMINNY_JWT=
SESSION_DOMAIN=app.staging.jiminny.com
SESSION_SECURE_COOKIE=true
SESSION_COOKIE=jmny_s
SESSION_CONNECTION=session
TWILIO_ACCOUNT_SID=
[ENV_SECRET]
TWILIO_LOG_LEVEL=debug
TWILIO_ACCOUNT_SID_JIMINNY=
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
SALESFORCE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/salesforce
SALESFORCE_SCOPE="api refresh_token web"
[ENV_SECRET]
[ENV_SECRET]
HUBSPOT_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/hubspot
HUBSPOT_SCOPE="crm.lists.read crm.objects.contacts.read crm.objects.contacts.write crm.objects.companies.write crm.schemas.contacts.read crm.objects.companies.read crm.objects.deals.read crm.objects.deals.write crm.schemas.companies.read crm.schemas.deals.read crm.objects.owners.read"
HUBSPOT_JOURNAL_SCOPE="developer.webhooks_journal.read developer.webhooks_journal.subscriptions.read developer.webhooks_journal.subscriptions.write developer.webhooks_journal.snapshots.read developer.webhooks_journal.snapshots.write"
HUBSPOT_APP_ID=38483
[ENV_SECRET]
[ENV_SECRET]
PIPEDRIVE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/pipedrive
PIPEDRIVE_SCOPE="base deals:full activities:full contacts:read search:read"
[ENV_SECRET]
[ENV_SECRET]
SALESLOFT_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/salesloft
SALESLOFT_SCOPE=""
[ENV_SECRET]
[ENV_SECRET]
AIRCALL_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/aircall
AIRCALL_SCOPE="public_api"
[ENV_SECRET]
[ENV_SECRET]
RINGCENTRAL_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/ringcentral
RINGCENTRAL_SCOPE=""
# Avaya is RingCentral owned, and it re-uses the same API
AVAYA_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/avaya
# Telus is RingCentral owned, and it re-uses the same API
TELUS_REDIRECT_URI=https://app.jiminny.com/auth/callback/telus
[ENV_SECRET]
[ENV_SECRET]
DIALPAD_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/dialpad
DIALPAD_SCOPE="recordings_export"
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
LINKEDIN_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/linkedin
LINKEDIN_SCOPE=""
[ENV_SECRET]
[ENV_SECRET]
LINKEDIN_CONFERENCE_REDIRECT_URI=https://app.staging.jiminny.com/conference/callback/linkedin
LINKEDIN_CONFERENCE_SCOPE=""
[ENV_SECRET]
#[ENV_SECRET]
[ENV_SECRET]
#[ENV_SECRET]
SLACK_APP_ID=A5YBAJ8TA
#SLACK_APP_ID=A1GQA48F9
[ENV_SECRET]
#[ENV_SECRET]
SLACK_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/slack
SLACK_SCOPE="channels:read,chat:write,chat:write.public,groups:read,im:read,im:write,users:read,users:read.email,incoming-webhook"
[ENV_SECRET]
[ENV_SECRET]
GOOGLE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/google
GOOGLE_SCOPE="openid email profile"
[ENV_SECRET]
MICROSOFT_OFFICE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/office
MICROSOFT_OFFICE_SCOPE="openid profile email offline_access"
TEAMS_COMPLIANCE_BOT_HOST=https://teams-bot.staging.jiminny.com
[ENV_SECRET]
[ENV_SECRET]
OUTREACH_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/outreach
OUTREACH_SCOPE="email users.read prospects.read accounts.read calls.read calls.write profiles.read tasks.read"
OUTREACH_APP_ID=c6399204e2cd687a3c7e32c542933d2933b4b05657f30e2c6b2b12639e2519c3
BULLHORN_CLIENT_ID=29deb258-d44b-426e-a3d1-5ccfb846b07a
[ENV_SECRET]
BULLHORN_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/bullhorn
BULLHORN_SCOPE=""
# Session TTL in minutes
BULLHORN_SESSION_TTL=1440
# Heartbeat interval in seconds, 0 to disable
BULLHORN_HEARTBEAT_INTERVAL=0
# Delays in seconds for retrying request important/transactional requests, 0 to disable
BULLHORN_RETRY_DELAYS=0
# Delay in seconds before a queued retry is executed. 0 to disable
BULLHORN_QUEUE_DELAYS=0
[ENV_SECRET]
ZOOM_PHONE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/zoom-phone
ZOOM_PHONE_SCOPE=""
ZOOM_PHONE_APP_ID=lKHL2yc0R3SDjX4b__JomA
[ENV_SECRET]
ZOOM_BOT_SCOPE=""
ZOOM_BOT_APP_ID=DHU7peAKRKG9YYOzXt_GZg
[ENV_SECRET]
## Integration.app translates multipe CRM apis for us
INTEGRATION_APP_ENABLED=true
INTEGRATION_APP_SALESFORCE_TEST_ENABLED=false
INTEGRATION_APP_URL=https://api.integration.app
[ENV_SECRET]
[ENV_SECRET]
# Talkdesk OAuth configuration
# Basic auth
TALKDESK_APP_ID=d0582f3489224089bbb31be4d54cf49e
[ENV_SECRET]
TALKDESK_SCOPE="openid refresh_token apps:read account:read users:read reports:read recordings:read contacts:read data-reports:read events:read webhooks-trigger:write"
TALKDESK_REDIRECT_URI=https://app.dev.jiminny.com/auth/callback/talkdesk
TALKDESK_SUBDOMAIN=jiminny-dev
TALKDESK_REGION=us
CHROME_WEB_STORE_EXT_ID=dccmccckblpfjiljnpephmlfhpmiamkd
[ENV_SECRET]
[ENV_SECRET]
S3_CLIENT_DATA_REGION=CLIENT_DATA_BUCKET_REGION
S3_CLIENT_DATA_BUCKET=CLIENT_DATA_BUCKET_NAME
S3_CLIENT_DATA_CLOUD_FRONT_HOST=CLIENT_DATA_BUCKET_CLOUD_FRONT_DOMAIN
S3_CLIENT_DATA_CLOUD_FRONT_URL=https://CLIENT_DATA_BUCKET_CLOUD_FRONT_DOMAIN
[ENV_SECRET]
S3_CLIENT_DATA_CLOUD_FRONT_SIGNED_COOKIE_DOMAIN='.app.staging.jiminny.com'
FILESYSTEM_DISK=client-data-cloud-ec2
FFPROBE_PATH=/usr/local/bin/ffprobe
FFMPEG_PATH=/usr/local/bin/ffmpeg
[ENV_SECRET]
[ENV_SECRET]
SQS_PREFIX=_QUEUE_PREFIX
SQS_QUEUE=_QUEUE_SQS_NAME
SQS_FIFO_QUEUE=_QUEUE_SQS_FIFO_NAME.FIFO
SQS_REGION=_QUEUE_REGION
KINESIS_AWS_REGION=us-east-2
INSIGHTS_EVENTS_AWS_KINESIS_STREAM_NAME=activity-event-insights
INSIGHTS_METRICS_AWS_KINESIS_STREAM_NAME=activity-metric-insights
GOOGLE_TEXT_RELAY_MAILBOX=catch-all
GOOGLE_TEXT_RELAY_HOST=txt.staging.jiminny.com
GOOGLE_TEXT_RELAY_TOPIC=projects/jiminny-web/topics/com.jiminny.staging.txt
GOOGLE_TEXT_RELAY_SUBSCRIPTION=projects/jiminny-web/subscriptions/staging-relay
ELASTICSEARCH_HOST=_AWS_ELASTIC_SEARCH_HOST
ELASTICSEARCH_PORT=80
ELASTICSEARCH_TRANSPORT=Http
ELASTICSEARCH_ACTIVITIES_INDEX=
CDN_URL=https://_PUBLIC_CDN_URL/
# CDN Logo URLs
LOGO_CDN_URL=https://cdn.staging.jiminny.com/img/logo_icon.png
FULL_LOGO_CDN_URL=https://cdn.staging.jiminny.com/img/full_jiminny_logo.png
[ENV_SECRET]
[ENV_SECRET]
SES_REGION=_AWS_SES_REGION
BUILD_NUMBER=_BUILD_NUMBER
BUILD_BRANCH=_BUILD_BRANCH
BUILD_COMMIT_SHA1=_BUILD_COMMIT_SHA1
SCHEDULER_LOG=/proc/1/fd/1
SILENCE_FILL=sin(10*2*PI*t)*sin(880*2*PI*t)
[ENV_SECRET]
[ENV_SECRET]
TRANSCRIPTION_PROVIDER_ASSEMBLYAI_BASEURL=https://api.assemblyai.com
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
TRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_BASEURL=https://jiminny-openai-stage.openai.azure.com/
TRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_DEPLOYMENT=staging-deployment
[ENV_SECRET]
PROPHET_AI_WRAPPER_TIMEOUT=600
PROPHET_AI_WRAPPER_CALL_READ_TIMEOUT=15
PROPHET_AI_WRAPPER_CALL_CONNECT_TIMEOUT=15
PROPHET_AI_WRAPPER_DEAL_READ_TIMEOUT=15
PROPHET_AI_WRAPPER_DEAL_CONNECT_TIMEOUT=60
PROPHET_AI_WRAPPER_ON_DEMAND_READ_TIMEOUT=60
PROPHET_AI_WRAPPER_ON_DEMAND_CONNECT_TIMEOUT=60
# AssemblyAI
AI_PROVIDER_ASSEMBLYAI_BASEURL=https://api.assemblyai.com
MEDIA_LIVE_AWS_REGION=us-east-2
[ENV_SECRET]
MEDIA_LIVE_INPUT_SECURITY_GROUP=2044916
MEDIA_LIVE_ACCESS_ROLE=arn:aws:iam::438740370364:role/medialive-role-jiminny-app-stage-stage
[ENV_SECRET]
MEDIA_LIVE_S3_REGION=us-east-2
MEDIA_LIVE_S3_BUCKET=stage-jiminny-medialive
MEDIA_STREAMING_AWS_REGION=us-east-2
MEDIA_STREAMING_AWS_ACCOUNT_ID=438740370364
MEDIA_STREAMING_AWS_STATE_MACHINE=msi_state_machine
MEDIA_STREAMING_AWS_API_GATEWAY_ENDPOINT=https://znq8b5kgkd.execute-api.us-east-2.amazonaws.com/staging/
[ENV_SECRET]
MEDIA_STREAMING_AWS_IVS_REGION=us-east-1
[ENV_SECRET]
UPLOADER_S3_REGION=us-east-2
UPLOADER_S3_BUCKET=stage-jiminny-uploader
LIVE_FEED_ENABLED_ITEMS=scorecard,activity_play,activity_shared,nudge_generated
APP_LOCAL_URL=https://your.ngrock.url
KMS_AWS_REGION=us-east-2
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
S3_FIVE9_REGION=us-east-2
S3_FIVE9_BUCKET=stage-jiminny-five9-client-data
S3_FIVE9_POLICY_ARN=jiminny-five9-client-policy
S3_FIVE9_USERNAME_PREFIX=client-five9-
[ENV_SECRET]
LARATRUST_ENABLE_CACHE=true
DEBUGBAR_ENABLED=false
[ENV_SECRET]
SAML2_ERROR_URL="/"
SAML2_LOGIN_URL="/dashboard"
SAML2_CONTACT_TECHNICAL_NAME="Engineering Support"
SAML2_CONTACT_TECHNICAL_EMAIL="[EMAIL]"
SAML2_CONTACT_SUPPORT_NAME="Support"
SAML2_CONTACT_SUPPORT_EMAIL="[EMAIL]"
SAML2_ORGANIZATION_NAME="Jiminny"
SAML2_ORGANIZATION_URL="https://jiminny.com"
MAXIO_API_ROUTE=https://jiminny-sandbox-two.chargify.com
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
RECALL_AI_API_URL=https://us-east-1.recall.ai/api/
RECALL_AI_ZOOM_REGISTERED_EMAIL=[EMAIL]
# System Environment Variable defined in AWS
# RECALL_AI_API_KEY=
# RECALL_AI_WEBHOOK_SECRET=
# RECALL_AI_ZOOM_BOT_APP_ID=
# ZOOM_BOT_REDIRECT_URI=
# should be same accross instances
[ENV_SECRET]
HUBSPOT_WEBHOOK_FORWARD_URLS='https://app.qa.jiminny.com,https://app.qai.jiminny.com,https://jupiter.staging.jiminny.com,https://uranus.staging.jiminny.com'
Sync Changes
Hide This Notification
Code changed:
Hide
7
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\UserPilot;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
class UserPilotClient
{
private const API_ENDPOINT = 'https://api.userpilot.io/v1/';
private const ANALYTICS_ENDPOINT = 'https://analytex.userpilot.io/v1/';
private function createRequest(): PendingRequest
{
return Http::withHeaders([
'X-API-Version' => '2020-09-22',
'Authorization' => 'Token ' . config('services.userpilot.key'),
]);
}
public function track(User $user, string $event, array $payload = []): void
{
if ($this->shouldRequest($user->getTeam()) === false) {
return;
}
$this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'track', [
'event_name' => $event,
'user_id' => $user->getUuid(),
'metadata' => $payload,
]);
}
public function upsertUser(User $user): void
{
if ($this->shouldRequest($user->getTeam()) === false) {
return;
}
$companyMetadata = $this->getCompanyMetadata($user->getTeam());
$companyMetadata['id'] = $user->getTeam()->getUuid();
$this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'identify', [
'user_id' => $user->getUuid(),
'metadata' => [
'name' => $user->name,
'first_name' => $user->getFirstName(),
'position' => $user->job ? $user->job->name : null,
'email' => $user->getEmailAddress(),
'created_at' => $user->getCreatedAt()->unix(),
'is_admin' => $user->hasRole(User::ROLE_ADMIN),
'is_manager' => $user->hasRole(User::ROLE_MANAGER),
'is_owner' => $user->isTeamOwner(),
'is_insights' => $user->hasRole(User::ROLE_ANALYST),
'is_recorder' => $user->hasRole(User::ROLE_RECORDER),
'is_jiminny_voice' => $user->hasRole(User::ROLE_RECORDER_AND_VOICE),
'is_listener' => $user->hasRole(User::ROLE_LISTENER),
'license' => null,
'team' => $user->group ? $user->group->name : null,
'language' => $user->getLanguage(),
'email_sync' => $user->isSyncEmailEnabled(),
],
'company' => $companyMetadata,
]);
}
public function upsertCompany(Team $team): void
{
$this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'companies/identify', [
'company_id' => $team->getUuid(),
'metadata' => $this->getCompanyMetadata($team),
]);
}
private function getCompanyMetadata(Team $team): array
{
return [
'created_at' => $team->getCreatedAt()->unix(),
'name' => $team->getName(),
'region' => config('jiminny.deploy_region'),
'crm' => $team->getCrmConfiguration()->getProviderName(),
'crm_installed_app_version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
'calendar' => $team->getCalendarProvider(),
'notification_provider' => $team->getNotificationProvider(),
'has_jiminny_voice' => $team->hasFeature(FeatureEnum::DIALER),
'tier' => $team->getTier()?->getTitle(),
];
}
public function deleteUser(User $user): void
{
if ($this->shouldRequest($user->getTeam()) === false) {
return;
}
$this->createRequest()->delete(self::API_ENDPOINT . 'users', [
'users' => [$user->getUuid()],
]);
}
public function deleteCompany(Team $team): void
{
if ($this->shouldRequest($team) === false) {
return;
}
$this->createRequest()->delete(self::API_ENDPOINT . 'companies', [
'companies' => [$team->getUuid()],
]);
}
public function shouldRequest(Team $team): bool
{
return config('services.userpilot.key') !== null && $team->getPartnerId() === Partner::PARTNER_DEFAULT;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20738-debug-AJ-tracking-UP, menu","depth":5,"help_text":"Git Branch: JY-20738-debug-AJ-tracking-UP","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,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ReportControllerTest","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ReportControllerTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ReportControllerTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"USERPI","depth":4,"value":"USERPI","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1/2","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"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},"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},"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},"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},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"APP_ENV=staging\nAPP_KEY=base64:1+v5Vc7TE57KCz8d8/7kP4t34hBobDNK9Mt8m/yaLnE=\nAPP_DEBUG=false\nLOG_CHANNEL=errorlog\nLOG_LEVEL=info\nAPP_URL=https://app.staging.jiminny.com\nAWS_DEFAULT_REGION=us-east-2\nAPP_REGION=us-east-2\nAPP_DEPLOY_REGION=us\n\nSECURITY_HEADER_HSTS_ENABLE=true\nCONTENT_SECURITY_POLICY_REPORT_URI=https://jmny.report-uri.com/r/d/csp/reportOnly\nSECURITY_HEADER_CUSTOM_CSP=null\n\nDB_CONNECTION=mysql\nDB_HOST=db\nDB_PORT=3306\nDB_DATABASE=jiminny\nDB_USERNAME=root\nDB_PASSWORD=53kr3t-$\n\nCASHIER_MODEL=Jiminny\\Models\\User\n\nBROADCAST_DRIVER=pusher\nCACHE_DRIVER=redis\nCACHE_PREFIX=jmny:\nSESSION_DRIVER=redis\nQUEUE_CONNECTION=sqs\nGITHUB_TOKEN=null\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=REDIS_ENDPOINT\nREDIS_PASSWORD=null\nREDIS_PORT=6379\nREDIS_PREFIX=jmny_database_\n\nSENTRY_DSN=https://3943fc8002e149d5afa2d7ae983efd7d:824c7ab750b340c38fb14d3b67b3c387@sentry.io/82419\nSENTRY_DSN_CONFERENCE=https://b0caaeacb6b9448fb1b51f7244c102a4:4babf8a90a7c42eb93fd47e46e388b87@sentry.io/298914\nSENTRY_DSN_FRONT_END=https://8cba05ef3e3f4f68a86d3a6d31465998@o36719.ingest.sentry.io/5627310\n\nLOGROCKET_CONFERENCE_ID=ponxaf/conference-staging\nLOGROCKET_APP_ID=ponxaf/platform-staging\n\nMAIL_MAILER=ses\nMAIL_HOST=email-smtp.us-east-1.amazonaws.com\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_ENCRYPTION=null\nMAIL_FROM_ADDRESS=no-reply@staging.jiminny.com\nMAIL_FROM_NAME=\"The Jiminny Team\"\nMAIL_DOMAIN=staging.jiminny.com\n\nPOSTMARK_TOKEN=\n#POSTMARK_RECIPIENT_OVERRIDE=\n\nTUNNEL_HOST=https://team:5hgTDUyu1vqCd@app.staging.jiminny.com\nOUTLOOK_URL=https://outlook.staging.jiminny.com\n\nPUSHER_APP_ID=\nPUSHER_APP_KEY=\nPUSHER_APP_SECRET=\nPUSHER_APP_CLUSTER=mt1\n\nAUTHY_SECRET=\n\nINTERCOM_APP_ID=naoxn74n\nINTERCOM_SECRET=2dZhae0Lzg-swJOM7VTMsqhka1X1tCWwT5tJLuT7\nINTERCOM_TOKEN=dG9rOmYzZWUwZmRkX2M1ZGNfNGRiNl85NmYxXzM3ZTY3NzM4NmNmZToxOjA=\n\nIPAPI_KEY=071cccd41a061ca7d2f0a7261535f1969618a6b5\nCIRCLECI_TOKEN=5323d5765e595d208048390d7e49b9d5cf83b6a2\n\nGOOGLE_MAP_KEY=AIzaSyBCm4fY6A9KYs7pO9ThKphNxL1EdfOstn0\n\nSTRIPE_MODEL=Jiminny\\Models\\Team\nSTRIPE_KEY=pk_test_7GnovpHxHSEiK6oYB5VPkXoN\nSTRIPE_SECRET=sk_test_vXV044hlMIZxzDfphaK4RBc5\n\nCASHIER_ENV=testing\n\nPLANHAT_ENABLED=\nPLANHAT_TENANT_UUID=\nPLANHAT_JIMINNY_JWT=\n\nSESSION_DOMAIN=app.staging.jiminny.com\nSESSION_SECURE_COOKIE=true\nSESSION_COOKIE=jmny_s\nSESSION_CONNECTION=session\n\nTWILIO_ACCOUNT_SID=\nTWILIO_AUTH_TOKEN=\nTWILIO_MESSAGING_SERVICE_ID=MGd59718736ce15007f67a820a0f3b949f\nTWILIO_LOG_LEVEL=debug\n\nTWILIO_ACCOUNT_SID_JIMINNY=\nTWILIO_AUTH_TOKEN_JIMINNY=\nTWILIO_SOFTPHONE_SID_JIMINNY=\n\nSALESFORCE_KEY=3MVG9szVa2RxsqBZ4EgEHLMc7am3yaaU4gmknSH8CrUO4gZZGdl1LLIrVLMCrdKfwhgaF6M9i3sWpte7mAdh4\nSALESFORCE_SECRET=473682276046725942\nSALESFORCE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/salesforce\nSALESFORCE_SCOPE=\"api refresh_token web\"\n\nHUBSPOT_KEY=51a336e6-7a44-4b57-b68c-0048235a2924\nHUBSPOT_SECRET=9090a005-87bb-412e-aaf2-109fd1543a49\nHUBSPOT_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/hubspot\nHUBSPOT_SCOPE=\"crm.lists.read crm.objects.contacts.read crm.objects.contacts.write crm.objects.companies.write crm.schemas.contacts.read crm.objects.companies.read crm.objects.deals.read crm.objects.deals.write crm.schemas.companies.read crm.schemas.deals.read crm.objects.owners.read\"\nHUBSPOT_JOURNAL_SCOPE=\"developer.webhooks_journal.read developer.webhooks_journal.subscriptions.read developer.webhooks_journal.subscriptions.write developer.webhooks_journal.snapshots.read developer.webhooks_journal.snapshots.write\"\nHUBSPOT_APP_ID=38483\n\nPIPEDRIVE_KEY=0446c88e4ff49311\nPIPEDRIVE_SECRET=604e0fe30ee2013a090dd5b7fc864f999fb7e9be\nPIPEDRIVE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/pipedrive\nPIPEDRIVE_SCOPE=\"base deals:full activities:full contacts:read search:read\"\n\nSALESLOFT_KEY=2feff299587a85b2ff6baff8ee3e3581500c617534610264673745d4ac37dc30\nSALESLOFT_SECRET=d72e5d3732e8a7ceb0c6095521291259d9860b3e1ccc9a3e6ba4e95a359b1a27\nSALESLOFT_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/salesloft\nSALESLOFT_SCOPE=\"\"\n\nAIRCALL_KEY=ceI07Z-3TSR6iem_jd_cOIchFk4GXOSEtfUZT6WhC3I\nAIRCALL_SECRET=1jp3VNq5RcTnwHr4Ny7Shdugcxyem6TAPUR21xS3LXU\nAIRCALL_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/aircall\nAIRCALL_SCOPE=\"public_api\"\n\nRINGCENTRAL_KEY=UpiQkIwJQi-zZeVx_pDtHw\nRINGCENTRAL_SECRET=ufLc2gSnTFyAXUpmx3zbPAG9bJmFWCSOW_9wOLKImQLw\nRINGCENTRAL_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/ringcentral\nRINGCENTRAL_SCOPE=\"\"\n# Avaya is RingCentral owned, and it re-uses the same API\nAVAYA_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/avaya\n# Telus is RingCentral owned, and it re-uses the same API\nTELUS_REDIRECT_URI=https://app.jiminny.com/auth/callback/telus\n\nDIALPAD_KEY=QpdCbgyXuZbY6gRFcmAKjqnAw\nDIALPAD_SECRET=c4sLFRNj6sXxUtxCmf9AvMFv3AnSTe3DgeRzKYXYxaagQmdUNt\nDIALPAD_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/dialpad\nDIALPAD_SCOPE=\"recordings_export\"\nDIALPAD_VERIFICATION_TOKEN=\"cOIchFk4GXOSEtfUZT6WhC3I\"\n\nLINKEDIN_KEY=77v17iybwaiuvr\nLINKEDIN_SECRET=EZih6HAHc9uAVD4u\nLINKEDIN_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/linkedin\nLINKEDIN_SCOPE=\"\"\n\nLINKEDIN_CONFERENCE_KEY=775824t9lk609k\nLINKEDIN_CONFERENCE_SECRET=4fhojJIl9AcO9aQq\nLINKEDIN_CONFERENCE_REDIRECT_URI=https://app.staging.jiminny.com/conference/callback/linkedin\nLINKEDIN_CONFERENCE_SCOPE=\"\"\n\nSLACK_KEY=36761956240.202384620928\n#SLACK_KEY=36761956240.50826144519\nSLACK_SECRET=60bb857a01726e3eabe0346b7bff451e\n#SLACK_SECRET=8ecf69a5958c7e812f5ea38b32233afc\nSLACK_APP_ID=A5YBAJ8TA\n#SLACK_APP_ID=A1GQA48F9\nSLACK_VERIFICATION_TOKEN=4Gs9U9SYy3zPGUG7q2CGBLTh\n#SLACK_VERIFICATION_TOKEN=cUBPfTlHaxFcILomFOU3Ib7p\nSLACK_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/slack\nSLACK_SCOPE=\"channels:read,chat:write,chat:write.public,groups:read,im:read,im:write,users:read,users:read.email,incoming-webhook\"\n\nGOOGLE_KEY=1092434469059-baf640lpublohrbvt5kbv356gstujeha.apps.googleusercontent.com\nGOOGLE_SECRET=4mvEc5uCGCN54e6mFmpARPaH\nGOOGLE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/google\nGOOGLE_SCOPE=\"openid email profile\"\n\nMICROSOFT_OFFICE_KEY=\nMICROSOFT_OFFICE_SECRET=\nMICROSOFT_OFFICE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/office\nMICROSOFT_OFFICE_SCOPE=\"openid profile email offline_access\"\n\nTEAMS_COMPLIANCE_BOT_HOST=https://teams-bot.staging.jiminny.com\nTEAMS_COMPLIANCE_BOT_SECRET=vA2cPoXeDQ4r4zVrJPYCeqVBiOZAdE\n\nOUTREACH_SECRET=0033829ba0025f7c24f345c894da529c044eac669c578c1bf7e7f167781a04ca\nOUTREACH_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/outreach\nOUTREACH_SCOPE=\"email users.read prospects.read accounts.read calls.read calls.write profiles.read tasks.read\"\nOUTREACH_APP_ID=c6399204e2cd687a3c7e32c542933d2933b4b05657f30e2c6b2b12639e2519c3\n\nBULLHORN_CLIENT_ID=29deb258-d44b-426e-a3d1-5ccfb846b07a\nBULLHORN_SECRET=F42eeFm6Z2T7W1SZ9eMvrEqj\nBULLHORN_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/bullhorn\nBULLHORN_SCOPE=\"\"\n# Session TTL in minutes\nBULLHORN_SESSION_TTL=1440\n# Heartbeat interval in seconds, 0 to disable\nBULLHORN_HEARTBEAT_INTERVAL=0\n# Delays in seconds for retrying request important/transactional requests, 0 to disable\nBULLHORN_RETRY_DELAYS=0\n# Delay in seconds before a queued retry is executed. 0 to disable\nBULLHORN_QUEUE_DELAYS=0\n\nZOOM_PHONE_SECRET=uFMxFlkZcZ5D3cf6I78OIQadD9BumJNo\nZOOM_PHONE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/zoom-phone\nZOOM_PHONE_SCOPE=\"\"\nZOOM_PHONE_APP_ID=lKHL2yc0R3SDjX4b__JomA\nZOOM_PHONE_SECRET_TOKEN=\n\nZOOM_BOT_SECRET=2asWNIrnLWNqTALn4WA4v1dghuEMvaH9\nZOOM_BOT_SCOPE=\"\"\nZOOM_BOT_APP_ID=DHU7peAKRKG9YYOzXt_GZg\nZOOM_BOT_SECRET_TOKEN=pJF-ChF4RcOgcDuFSE9sEw\n\n## Integration.app translates multipe CRM apis for us\nINTEGRATION_APP_ENABLED=true\nINTEGRATION_APP_SALESFORCE_TEST_ENABLED=false\nINTEGRATION_APP_URL=https://api.integration.app\nINTEGRATION_APP_KEY=687a59f7-2276-486c-8306-14507fd797ae\nINTEGRATION_APP_SECRET=3da071e082e6627585962cf971f786ddd632c0fb246f27406a4ad1f365fb9ce8\n\n# Talkdesk OAuth configuration\n# Basic auth\nTALKDESK_APP_ID=d0582f3489224089bbb31be4d54cf49e\nTALKDESK_SECRET=OoVYKKyEpIxlXytoFm3B_WvAwIM73trR_yp9K6yFp-qoEGVcw3EZXL5B4MwtuzZ7PaJvHt3AWQS5ybIc3cEyaQ\nTALKDESK_SCOPE=\"openid refresh_token apps:read account:read users:read reports:read recordings:read contacts:read data-reports:read events:read webhooks-trigger:write\"\nTALKDESK_REDIRECT_URI=https://app.dev.jiminny.com/auth/callback/talkdesk\nTALKDESK_SUBDOMAIN=jiminny-dev\nTALKDESK_REGION=us\n\nCHROME_WEB_STORE_EXT_ID=dccmccckblpfjiljnpephmlfhpmiamkd\n\nS3_CLIENT_DATA_AWS_ACCESS_KEY=CLIENT_DATA_ACCESS_KEY\nS3_CLIENT_DATA_AWS_SECRET_KEY=CLIENT_DATA_SECRET_KEY\nS3_CLIENT_DATA_REGION=CLIENT_DATA_BUCKET_REGION\nS3_CLIENT_DATA_BUCKET=CLIENT_DATA_BUCKET_NAME\nS3_CLIENT_DATA_CLOUD_FRONT_HOST=CLIENT_DATA_BUCKET_CLOUD_FRONT_DOMAIN\nS3_CLIENT_DATA_CLOUD_FRONT_URL=https://CLIENT_DATA_BUCKET_CLOUD_FRONT_DOMAIN\nS3_CLIENT_DATA_CLOUD_FRONT_KEY_PAIR_ID=\nS3_CLIENT_DATA_CLOUD_FRONT_PRIVATE_KEY=/home/jiminny/storage/app/client-data-cf.pem\nS3_CLIENT_DATA_CLOUD_FRONT_SIGNED_COOKIE_DOMAIN='.app.staging.jiminny.com'\n\nFILESYSTEM_DISK=client-data-cloud-ec2\n\nFFPROBE_PATH=/usr/local/bin/ffprobe\nFFMPEG_PATH=/usr/local/bin/ffmpeg\n\nSQS_AWS_ACCESS_KEY=_QUEUE_ACCESS_KEY\nSQS_AWS_SECRET_KEY=_QUEUE_SECRET_KEY\nSQS_PREFIX=_QUEUE_PREFIX\nSQS_QUEUE=_QUEUE_SQS_NAME\nSQS_FIFO_QUEUE=_QUEUE_SQS_FIFO_NAME.FIFO\nSQS_REGION=_QUEUE_REGION\n\nKINESIS_AWS_REGION=us-east-2\n\nINSIGHTS_EVENTS_AWS_KINESIS_STREAM_NAME=activity-event-insights\nINSIGHTS_METRICS_AWS_KINESIS_STREAM_NAME=activity-metric-insights\n\nGOOGLE_TEXT_RELAY_MAILBOX=catch-all\nGOOGLE_TEXT_RELAY_HOST=txt.staging.jiminny.com\nGOOGLE_TEXT_RELAY_TOPIC=projects/jiminny-web/topics/com.jiminny.staging.txt\nGOOGLE_TEXT_RELAY_SUBSCRIPTION=projects/jiminny-web/subscriptions/staging-relay\n\nELASTICSEARCH_HOST=_AWS_ELASTIC_SEARCH_HOST\nELASTICSEARCH_PORT=80\nELASTICSEARCH_TRANSPORT=Http\n\nELASTICSEARCH_ACTIVITIES_INDEX=\n\nCDN_URL=https://_PUBLIC_CDN_URL/\n\n# CDN Logo URLs\nLOGO_CDN_URL=https://cdn.staging.jiminny.com/img/logo_icon.png\nFULL_LOGO_CDN_URL=https://cdn.staging.jiminny.com/img/full_jiminny_logo.png\n\nSES_ACCESS_KEY=_AWS_SES_ACCESS_KEY\nSES_SECRET_KEY=_AWS_SES_SECRET_KEY\nSES_REGION=_AWS_SES_REGION\n\nBUILD_NUMBER=_BUILD_NUMBER\nBUILD_BRANCH=_BUILD_BRANCH\nBUILD_COMMIT_SHA1=_BUILD_COMMIT_SHA1\n\nSCHEDULER_LOG=/proc/1/fd/1\nSILENCE_FILL=sin(10*2*PI*t)*sin(880*2*PI*t)\n\nSTATSD_API_KEY=f061d0393b6e38e5485b7ec26f57f6ae\nSTATSD_APPLICATION_KEY=de166e66596542d5de7689f6cd72e04a403df454\n\nTRANSCRIPTION_PROVIDER_ASSEMBLYAI_BASEURL=https://api.assemblyai.com\nTRANSCRIPTION_PROVIDER_ASSEMBLYAI_TOKEN=\n\nTRANSCRIPTION_PROVIDER_GLADIA_BASEURL=https://api.us.gladia.io\nTRANSCRIPTION_PROVIDER_GLADIA_API_KEY=\n\nTRANSCRIPTION_PROVIDER_MICROSOFT_PARTNER_BASEURL=https://eastus.api.cognitive.microsoft.com/\nTRANSCRIPTION_PROVIDER_MICROSOFT_PARTNER_TOKEN=\n\nTRANSCRIPTION_PROVIDER_GONG_BASEURL=https://api.gong.io/v2/\n\nTRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_BASEURL=https://jiminny-openai-stage.openai.azure.com/\nTRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_DEPLOYMENT=staging-deployment\nTRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_APIKEY=\n\nPROPHET_AI_WRAPPER_URL=https://prophet.staging.jiminny.com\nPROPHET_AI_WRAPPER_TIMEOUT=600\nPROPHET_AI_WRAPPER_CALL_READ_TIMEOUT=15\nPROPHET_AI_WRAPPER_CALL_CONNECT_TIMEOUT=15\nPROPHET_AI_WRAPPER_DEAL_READ_TIMEOUT=15\nPROPHET_AI_WRAPPER_DEAL_CONNECT_TIMEOUT=60\nPROPHET_AI_WRAPPER_ON_DEMAND_READ_TIMEOUT=60\nPROPHET_AI_WRAPPER_ON_DEMAND_CONNECT_TIMEOUT=60\n\n# AssemblyAI\nAI_PROVIDER_ASSEMBLYAI_BASEURL=https://api.assemblyai.com\n\nMEDIA_LIVE_AWS_REGION=us-east-2\nMEDIA_LIVE_AWS_ACCESS_KEY=\nMEDIA_LIVE_AWS_SECRET_KEY=\nMEDIA_LIVE_INPUT_SECURITY_GROUP=2044916\nMEDIA_LIVE_ACCESS_ROLE=arn:aws:iam::438740370364:role/medialive-role-jiminny-app-stage-stage\n\nMEDIA_LIVE_S3_AWS_ACCESS_KEY=\nMEDIA_LIVE_S3_AWS_SECRET_KEY=\nMEDIA_LIVE_S3_REGION=us-east-2\nMEDIA_LIVE_S3_BUCKET=stage-jiminny-medialive\n\nMEDIA_STREAMING_AWS_REGION=us-east-2\nMEDIA_STREAMING_AWS_ACCOUNT_ID=438740370364\nMEDIA_STREAMING_AWS_STATE_MACHINE=msi_state_machine\nMEDIA_STREAMING_AWS_API_GATEWAY_ENDPOINT=https://znq8b5kgkd.execute-api.us-east-2.amazonaws.com/staging/\nMEDIA_STREAMING_AWS_API_KEY=Mz4olw429R5DH9GIchqW33pu7eqylTHGFblJyqm7\n\nMEDIA_STREAMING_AWS_IVS_REGION=us-east-1\nMEDIA_STREAMING_AWS_IVS_KEY=\nMEDIA_STREAMING_AWS_IVS_SECRET=\n\nUPLOADER_S3_REGION=us-east-2\nUPLOADER_S3_BUCKET=stage-jiminny-uploader\n\nLIVE_FEED_ENABLED_ITEMS=scorecard,activity_play,activity_shared,nudge_generated\n\nAPP_LOCAL_URL=https://your.ngrock.url\n\nKMS_AWS_REGION=us-east-2\nKMS_AWS_ACCESS_KEY=\nKMS_AWS_SECRET_KEY=\nKMS_AWS_MASTER_KEY_ALIAS=alias/access-tokens\n\nS3_FIVE9_ACCESS_KEY=\nS3_FIVE9_SECRET_KEY=\nS3_FIVE9_REGION=us-east-2\nS3_FIVE9_BUCKET=stage-jiminny-five9-client-data\nS3_FIVE9_POLICY_ARN=jiminny-five9-client-policy\nS3_FIVE9_USERNAME_PREFIX=client-five9-\n\nENCRYPTED_TOKEN_MANAGER_MODE=encrypted\n\nLARATRUST_ENABLE_CACHE=true\n\nDEBUGBAR_ENABLED=false\n\nSAML2_SP_CERT_PRIVATEKEY=\"/home/jiminny/storage/saml.pem\"\n\nSAML2_ERROR_URL=\"/\"\nSAML2_LOGIN_URL=\"/dashboard\"\n\nSAML2_CONTACT_TECHNICAL_NAME=\"Engineering Support\"\nSAML2_CONTACT_TECHNICAL_EMAIL=\"engineers@jiminny.com\"\n\nSAML2_CONTACT_SUPPORT_NAME=\"Support\"\nSAML2_CONTACT_SUPPORT_EMAIL=\"support@jiminny.com\"\n\nSAML2_ORGANIZATION_NAME=\"Jiminny\"\nSAML2_ORGANIZATION_URL=\"https://jiminny.com\"\n\nMAXIO_API_ROUTE=https://jiminny-sandbox-two.chargify.com\nMAXIO_API_KEY=TwD5RTL6FOW3Hi25exynG2yvja3DBuG1Zyg2YTNhPDg\nMAXIO_PASSWORD=\n\nKIOSK_TEAMS=1\n\nUSERPILOT_KEY=7014b4a5b4defc7a\nUSERPILOT_TOKEN=NX-3f3884db\n\nRECALL_AI_API_URL=https://us-east-1.recall.ai/api/\nRECALL_AI_ZOOM_REGISTERED_EMAIL=notetaker@jiminny.com\n\n# System Environment Variable defined in AWS\n# RECALL_AI_API_KEY=\n# RECALL_AI_WEBHOOK_SECRET=\n# RECALL_AI_ZOOM_BOT_APP_ID=\n# ZOOM_BOT_REDIRECT_URI=\n\n# should be same accross instances\nINTERNAL_WEBHOOK_SECRET=d6e2f3d842d8c97d26d65c5a53442841dbb928a5fcfba160be7f5142fea5b322\n\nHUBSPOT_WEBHOOK_FORWARD_URLS='https://app.qa.jiminny.com,https://app.qai.jiminny.com,https://jupiter.staging.jiminny.com,https://uranus.staging.jiminny.com'","depth":4,"value":"APP_ENV=staging\nAPP_KEY=base64:1+v5Vc7TE57KCz8d8/7kP4t34hBobDNK9Mt8m/yaLnE=\nAPP_DEBUG=false\nLOG_CHANNEL=errorlog\nLOG_LEVEL=info\nAPP_URL=https://app.staging.jiminny.com\nAWS_DEFAULT_REGION=us-east-2\nAPP_REGION=us-east-2\nAPP_DEPLOY_REGION=us\n\nSECURITY_HEADER_HSTS_ENABLE=true\nCONTENT_SECURITY_POLICY_REPORT_URI=https://jmny.report-uri.com/r/d/csp/reportOnly\nSECURITY_HEADER_CUSTOM_CSP=null\n\nDB_CONNECTION=mysql\nDB_HOST=db\nDB_PORT=3306\nDB_DATABASE=jiminny\nDB_USERNAME=root\nDB_PASSWORD=53kr3t-$\n\nCASHIER_MODEL=Jiminny\\Models\\User\n\nBROADCAST_DRIVER=pusher\nCACHE_DRIVER=redis\nCACHE_PREFIX=jmny:\nSESSION_DRIVER=redis\nQUEUE_CONNECTION=sqs\nGITHUB_TOKEN=null\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=REDIS_ENDPOINT\nREDIS_PASSWORD=null\nREDIS_PORT=6379\nREDIS_PREFIX=jmny_database_\n\nSENTRY_DSN=https://3943fc8002e149d5afa2d7ae983efd7d:824c7ab750b340c38fb14d3b67b3c387@sentry.io/82419\nSENTRY_DSN_CONFERENCE=https://b0caaeacb6b9448fb1b51f7244c102a4:4babf8a90a7c42eb93fd47e46e388b87@sentry.io/298914\nSENTRY_DSN_FRONT_END=https://8cba05ef3e3f4f68a86d3a6d31465998@o36719.ingest.sentry.io/5627310\n\nLOGROCKET_CONFERENCE_ID=ponxaf/conference-staging\nLOGROCKET_APP_ID=ponxaf/platform-staging\n\nMAIL_MAILER=ses\nMAIL_HOST=email-smtp.us-east-1.amazonaws.com\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_ENCRYPTION=null\nMAIL_FROM_ADDRESS=no-reply@staging.jiminny.com\nMAIL_FROM_NAME=\"The Jiminny Team\"\nMAIL_DOMAIN=staging.jiminny.com\n\nPOSTMARK_TOKEN=\n#POSTMARK_RECIPIENT_OVERRIDE=\n\nTUNNEL_HOST=https://team:5hgTDUyu1vqCd@app.staging.jiminny.com\nOUTLOOK_URL=https://outlook.staging.jiminny.com\n\nPUSHER_APP_ID=\nPUSHER_APP_KEY=\nPUSHER_APP_SECRET=\nPUSHER_APP_CLUSTER=mt1\n\nAUTHY_SECRET=\n\nINTERCOM_APP_ID=naoxn74n\nINTERCOM_SECRET=2dZhae0Lzg-swJOM7VTMsqhka1X1tCWwT5tJLuT7\nINTERCOM_TOKEN=dG9rOmYzZWUwZmRkX2M1ZGNfNGRiNl85NmYxXzM3ZTY3NzM4NmNmZToxOjA=\n\nIPAPI_KEY=071cccd41a061ca7d2f0a7261535f1969618a6b5\nCIRCLECI_TOKEN=5323d5765e595d208048390d7e49b9d5cf83b6a2\n\nGOOGLE_MAP_KEY=AIzaSyBCm4fY6A9KYs7pO9ThKphNxL1EdfOstn0\n\nSTRIPE_MODEL=Jiminny\\Models\\Team\nSTRIPE_KEY=pk_test_7GnovpHxHSEiK6oYB5VPkXoN\nSTRIPE_SECRET=sk_test_vXV044hlMIZxzDfphaK4RBc5\n\nCASHIER_ENV=testing\n\nPLANHAT_ENABLED=\nPLANHAT_TENANT_UUID=\nPLANHAT_JIMINNY_JWT=\n\nSESSION_DOMAIN=app.staging.jiminny.com\nSESSION_SECURE_COOKIE=true\nSESSION_COOKIE=jmny_s\nSESSION_CONNECTION=session\n\nTWILIO_ACCOUNT_SID=\nTWILIO_AUTH_TOKEN=\nTWILIO_MESSAGING_SERVICE_ID=MGd59718736ce15007f67a820a0f3b949f\nTWILIO_LOG_LEVEL=debug\n\nTWILIO_ACCOUNT_SID_JIMINNY=\nTWILIO_AUTH_TOKEN_JIMINNY=\nTWILIO_SOFTPHONE_SID_JIMINNY=\n\nSALESFORCE_KEY=3MVG9szVa2RxsqBZ4EgEHLMc7am3yaaU4gmknSH8CrUO4gZZGdl1LLIrVLMCrdKfwhgaF6M9i3sWpte7mAdh4\nSALESFORCE_SECRET=473682276046725942\nSALESFORCE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/salesforce\nSALESFORCE_SCOPE=\"api refresh_token web\"\n\nHUBSPOT_KEY=51a336e6-7a44-4b57-b68c-0048235a2924\nHUBSPOT_SECRET=9090a005-87bb-412e-aaf2-109fd1543a49\nHUBSPOT_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/hubspot\nHUBSPOT_SCOPE=\"crm.lists.read crm.objects.contacts.read crm.objects.contacts.write crm.objects.companies.write crm.schemas.contacts.read crm.objects.companies.read crm.objects.deals.read crm.objects.deals.write crm.schemas.companies.read crm.schemas.deals.read crm.objects.owners.read\"\nHUBSPOT_JOURNAL_SCOPE=\"developer.webhooks_journal.read developer.webhooks_journal.subscriptions.read developer.webhooks_journal.subscriptions.write developer.webhooks_journal.snapshots.read developer.webhooks_journal.snapshots.write\"\nHUBSPOT_APP_ID=38483\n\nPIPEDRIVE_KEY=0446c88e4ff49311\nPIPEDRIVE_SECRET=604e0fe30ee2013a090dd5b7fc864f999fb7e9be\nPIPEDRIVE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/pipedrive\nPIPEDRIVE_SCOPE=\"base deals:full activities:full contacts:read search:read\"\n\nSALESLOFT_KEY=2feff299587a85b2ff6baff8ee3e3581500c617534610264673745d4ac37dc30\nSALESLOFT_SECRET=d72e5d3732e8a7ceb0c6095521291259d9860b3e1ccc9a3e6ba4e95a359b1a27\nSALESLOFT_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/salesloft\nSALESLOFT_SCOPE=\"\"\n\nAIRCALL_KEY=ceI07Z-3TSR6iem_jd_cOIchFk4GXOSEtfUZT6WhC3I\nAIRCALL_SECRET=1jp3VNq5RcTnwHr4Ny7Shdugcxyem6TAPUR21xS3LXU\nAIRCALL_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/aircall\nAIRCALL_SCOPE=\"public_api\"\n\nRINGCENTRAL_KEY=UpiQkIwJQi-zZeVx_pDtHw\nRINGCENTRAL_SECRET=ufLc2gSnTFyAXUpmx3zbPAG9bJmFWCSOW_9wOLKImQLw\nRINGCENTRAL_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/ringcentral\nRINGCENTRAL_SCOPE=\"\"\n# Avaya is RingCentral owned, and it re-uses the same API\nAVAYA_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/avaya\n# Telus is RingCentral owned, and it re-uses the same API\nTELUS_REDIRECT_URI=https://app.jiminny.com/auth/callback/telus\n\nDIALPAD_KEY=QpdCbgyXuZbY6gRFcmAKjqnAw\nDIALPAD_SECRET=c4sLFRNj6sXxUtxCmf9AvMFv3AnSTe3DgeRzKYXYxaagQmdUNt\nDIALPAD_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/dialpad\nDIALPAD_SCOPE=\"recordings_export\"\nDIALPAD_VERIFICATION_TOKEN=\"cOIchFk4GXOSEtfUZT6WhC3I\"\n\nLINKEDIN_KEY=77v17iybwaiuvr\nLINKEDIN_SECRET=EZih6HAHc9uAVD4u\nLINKEDIN_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/linkedin\nLINKEDIN_SCOPE=\"\"\n\nLINKEDIN_CONFERENCE_KEY=775824t9lk609k\nLINKEDIN_CONFERENCE_SECRET=4fhojJIl9AcO9aQq\nLINKEDIN_CONFERENCE_REDIRECT_URI=https://app.staging.jiminny.com/conference/callback/linkedin\nLINKEDIN_CONFERENCE_SCOPE=\"\"\n\nSLACK_KEY=36761956240.202384620928\n#SLACK_KEY=36761956240.50826144519\nSLACK_SECRET=60bb857a01726e3eabe0346b7bff451e\n#SLACK_SECRET=8ecf69a5958c7e812f5ea38b32233afc\nSLACK_APP_ID=A5YBAJ8TA\n#SLACK_APP_ID=A1GQA48F9\nSLACK_VERIFICATION_TOKEN=4Gs9U9SYy3zPGUG7q2CGBLTh\n#SLACK_VERIFICATION_TOKEN=cUBPfTlHaxFcILomFOU3Ib7p\nSLACK_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/slack\nSLACK_SCOPE=\"channels:read,chat:write,chat:write.public,groups:read,im:read,im:write,users:read,users:read.email,incoming-webhook\"\n\nGOOGLE_KEY=1092434469059-baf640lpublohrbvt5kbv356gstujeha.apps.googleusercontent.com\nGOOGLE_SECRET=4mvEc5uCGCN54e6mFmpARPaH\nGOOGLE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/google\nGOOGLE_SCOPE=\"openid email profile\"\n\nMICROSOFT_OFFICE_KEY=\nMICROSOFT_OFFICE_SECRET=\nMICROSOFT_OFFICE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/office\nMICROSOFT_OFFICE_SCOPE=\"openid profile email offline_access\"\n\nTEAMS_COMPLIANCE_BOT_HOST=https://teams-bot.staging.jiminny.com\nTEAMS_COMPLIANCE_BOT_SECRET=vA2cPoXeDQ4r4zVrJPYCeqVBiOZAdE\n\nOUTREACH_SECRET=0033829ba0025f7c24f345c894da529c044eac669c578c1bf7e7f167781a04ca\nOUTREACH_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/outreach\nOUTREACH_SCOPE=\"email users.read prospects.read accounts.read calls.read calls.write profiles.read tasks.read\"\nOUTREACH_APP_ID=c6399204e2cd687a3c7e32c542933d2933b4b05657f30e2c6b2b12639e2519c3\n\nBULLHORN_CLIENT_ID=29deb258-d44b-426e-a3d1-5ccfb846b07a\nBULLHORN_SECRET=F42eeFm6Z2T7W1SZ9eMvrEqj\nBULLHORN_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/bullhorn\nBULLHORN_SCOPE=\"\"\n# Session TTL in minutes\nBULLHORN_SESSION_TTL=1440\n# Heartbeat interval in seconds, 0 to disable\nBULLHORN_HEARTBEAT_INTERVAL=0\n# Delays in seconds for retrying request important/transactional requests, 0 to disable\nBULLHORN_RETRY_DELAYS=0\n# Delay in seconds before a queued retry is executed. 0 to disable\nBULLHORN_QUEUE_DELAYS=0\n\nZOOM_PHONE_SECRET=uFMxFlkZcZ5D3cf6I78OIQadD9BumJNo\nZOOM_PHONE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/zoom-phone\nZOOM_PHONE_SCOPE=\"\"\nZOOM_PHONE_APP_ID=lKHL2yc0R3SDjX4b__JomA\nZOOM_PHONE_SECRET_TOKEN=\n\nZOOM_BOT_SECRET=2asWNIrnLWNqTALn4WA4v1dghuEMvaH9\nZOOM_BOT_SCOPE=\"\"\nZOOM_BOT_APP_ID=DHU7peAKRKG9YYOzXt_GZg\nZOOM_BOT_SECRET_TOKEN=pJF-ChF4RcOgcDuFSE9sEw\n\n## Integration.app translates multipe CRM apis for us\nINTEGRATION_APP_ENABLED=true\nINTEGRATION_APP_SALESFORCE_TEST_ENABLED=false\nINTEGRATION_APP_URL=https://api.integration.app\nINTEGRATION_APP_KEY=687a59f7-2276-486c-8306-14507fd797ae\nINTEGRATION_APP_SECRET=3da071e082e6627585962cf971f786ddd632c0fb246f27406a4ad1f365fb9ce8\n\n# Talkdesk OAuth configuration\n# Basic auth\nTALKDESK_APP_ID=d0582f3489224089bbb31be4d54cf49e\nTALKDESK_SECRET=OoVYKKyEpIxlXytoFm3B_WvAwIM73trR_yp9K6yFp-qoEGVcw3EZXL5B4MwtuzZ7PaJvHt3AWQS5ybIc3cEyaQ\nTALKDESK_SCOPE=\"openid refresh_token apps:read account:read users:read reports:read recordings:read contacts:read data-reports:read events:read webhooks-trigger:write\"\nTALKDESK_REDIRECT_URI=https://app.dev.jiminny.com/auth/callback/talkdesk\nTALKDESK_SUBDOMAIN=jiminny-dev\nTALKDESK_REGION=us\n\nCHROME_WEB_STORE_EXT_ID=dccmccckblpfjiljnpephmlfhpmiamkd\n\nS3_CLIENT_DATA_AWS_ACCESS_KEY=CLIENT_DATA_ACCESS_KEY\nS3_CLIENT_DATA_AWS_SECRET_KEY=CLIENT_DATA_SECRET_KEY\nS3_CLIENT_DATA_REGION=CLIENT_DATA_BUCKET_REGION\nS3_CLIENT_DATA_BUCKET=CLIENT_DATA_BUCKET_NAME\nS3_CLIENT_DATA_CLOUD_FRONT_HOST=CLIENT_DATA_BUCKET_CLOUD_FRONT_DOMAIN\nS3_CLIENT_DATA_CLOUD_FRONT_URL=https://CLIENT_DATA_BUCKET_CLOUD_FRONT_DOMAIN\nS3_CLIENT_DATA_CLOUD_FRONT_KEY_PAIR_ID=\nS3_CLIENT_DATA_CLOUD_FRONT_PRIVATE_KEY=/home/jiminny/storage/app/client-data-cf.pem\nS3_CLIENT_DATA_CLOUD_FRONT_SIGNED_COOKIE_DOMAIN='.app.staging.jiminny.com'\n\nFILESYSTEM_DISK=client-data-cloud-ec2\n\nFFPROBE_PATH=/usr/local/bin/ffprobe\nFFMPEG_PATH=/usr/local/bin/ffmpeg\n\nSQS_AWS_ACCESS_KEY=_QUEUE_ACCESS_KEY\nSQS_AWS_SECRET_KEY=_QUEUE_SECRET_KEY\nSQS_PREFIX=_QUEUE_PREFIX\nSQS_QUEUE=_QUEUE_SQS_NAME\nSQS_FIFO_QUEUE=_QUEUE_SQS_FIFO_NAME.FIFO\nSQS_REGION=_QUEUE_REGION\n\nKINESIS_AWS_REGION=us-east-2\n\nINSIGHTS_EVENTS_AWS_KINESIS_STREAM_NAME=activity-event-insights\nINSIGHTS_METRICS_AWS_KINESIS_STREAM_NAME=activity-metric-insights\n\nGOOGLE_TEXT_RELAY_MAILBOX=catch-all\nGOOGLE_TEXT_RELAY_HOST=txt.staging.jiminny.com\nGOOGLE_TEXT_RELAY_TOPIC=projects/jiminny-web/topics/com.jiminny.staging.txt\nGOOGLE_TEXT_RELAY_SUBSCRIPTION=projects/jiminny-web/subscriptions/staging-relay\n\nELASTICSEARCH_HOST=_AWS_ELASTIC_SEARCH_HOST\nELASTICSEARCH_PORT=80\nELASTICSEARCH_TRANSPORT=Http\n\nELASTICSEARCH_ACTIVITIES_INDEX=\n\nCDN_URL=https://_PUBLIC_CDN_URL/\n\n# CDN Logo URLs\nLOGO_CDN_URL=https://cdn.staging.jiminny.com/img/logo_icon.png\nFULL_LOGO_CDN_URL=https://cdn.staging.jiminny.com/img/full_jiminny_logo.png\n\nSES_ACCESS_KEY=_AWS_SES_ACCESS_KEY\nSES_SECRET_KEY=_AWS_SES_SECRET_KEY\nSES_REGION=_AWS_SES_REGION\n\nBUILD_NUMBER=_BUILD_NUMBER\nBUILD_BRANCH=_BUILD_BRANCH\nBUILD_COMMIT_SHA1=_BUILD_COMMIT_SHA1\n\nSCHEDULER_LOG=/proc/1/fd/1\nSILENCE_FILL=sin(10*2*PI*t)*sin(880*2*PI*t)\n\nSTATSD_API_KEY=f061d0393b6e38e5485b7ec26f57f6ae\nSTATSD_APPLICATION_KEY=de166e66596542d5de7689f6cd72e04a403df454\n\nTRANSCRIPTION_PROVIDER_ASSEMBLYAI_BASEURL=https://api.assemblyai.com\nTRANSCRIPTION_PROVIDER_ASSEMBLYAI_TOKEN=\n\nTRANSCRIPTION_PROVIDER_GLADIA_BASEURL=https://api.us.gladia.io\nTRANSCRIPTION_PROVIDER_GLADIA_API_KEY=\n\nTRANSCRIPTION_PROVIDER_MICROSOFT_PARTNER_BASEURL=https://eastus.api.cognitive.microsoft.com/\nTRANSCRIPTION_PROVIDER_MICROSOFT_PARTNER_TOKEN=\n\nTRANSCRIPTION_PROVIDER_GONG_BASEURL=https://api.gong.io/v2/\n\nTRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_BASEURL=https://jiminny-openai-stage.openai.azure.com/\nTRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_DEPLOYMENT=staging-deployment\nTRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_APIKEY=\n\nPROPHET_AI_WRAPPER_URL=https://prophet.staging.jiminny.com\nPROPHET_AI_WRAPPER_TIMEOUT=600\nPROPHET_AI_WRAPPER_CALL_READ_TIMEOUT=15\nPROPHET_AI_WRAPPER_CALL_CONNECT_TIMEOUT=15\nPROPHET_AI_WRAPPER_DEAL_READ_TIMEOUT=15\nPROPHET_AI_WRAPPER_DEAL_CONNECT_TIMEOUT=60\nPROPHET_AI_WRAPPER_ON_DEMAND_READ_TIMEOUT=60\nPROPHET_AI_WRAPPER_ON_DEMAND_CONNECT_TIMEOUT=60\n\n# AssemblyAI\nAI_PROVIDER_ASSEMBLYAI_BASEURL=https://api.assemblyai.com\n\nMEDIA_LIVE_AWS_REGION=us-east-2\nMEDIA_LIVE_AWS_ACCESS_KEY=\nMEDIA_LIVE_AWS_SECRET_KEY=\nMEDIA_LIVE_INPUT_SECURITY_GROUP=2044916\nMEDIA_LIVE_ACCESS_ROLE=arn:aws:iam::438740370364:role/medialive-role-jiminny-app-stage-stage\n\nMEDIA_LIVE_S3_AWS_ACCESS_KEY=\nMEDIA_LIVE_S3_AWS_SECRET_KEY=\nMEDIA_LIVE_S3_REGION=us-east-2\nMEDIA_LIVE_S3_BUCKET=stage-jiminny-medialive\n\nMEDIA_STREAMING_AWS_REGION=us-east-2\nMEDIA_STREAMING_AWS_ACCOUNT_ID=438740370364\nMEDIA_STREAMING_AWS_STATE_MACHINE=msi_state_machine\nMEDIA_STREAMING_AWS_API_GATEWAY_ENDPOINT=https://znq8b5kgkd.execute-api.us-east-2.amazonaws.com/staging/\nMEDIA_STREAMING_AWS_API_KEY=Mz4olw429R5DH9GIchqW33pu7eqylTHGFblJyqm7\n\nMEDIA_STREAMING_AWS_IVS_REGION=us-east-1\nMEDIA_STREAMING_AWS_IVS_KEY=\nMEDIA_STREAMING_AWS_IVS_SECRET=\n\nUPLOADER_S3_REGION=us-east-2\nUPLOADER_S3_BUCKET=stage-jiminny-uploader\n\nLIVE_FEED_ENABLED_ITEMS=scorecard,activity_play,activity_shared,nudge_generated\n\nAPP_LOCAL_URL=https://your.ngrock.url\n\nKMS_AWS_REGION=us-east-2\nKMS_AWS_ACCESS_KEY=\nKMS_AWS_SECRET_KEY=\nKMS_AWS_MASTER_KEY_ALIAS=alias/access-tokens\n\nS3_FIVE9_ACCESS_KEY=\nS3_FIVE9_SECRET_KEY=\nS3_FIVE9_REGION=us-east-2\nS3_FIVE9_BUCKET=stage-jiminny-five9-client-data\nS3_FIVE9_POLICY_ARN=jiminny-five9-client-policy\nS3_FIVE9_USERNAME_PREFIX=client-five9-\n\nENCRYPTED_TOKEN_MANAGER_MODE=encrypted\n\nLARATRUST_ENABLE_CACHE=true\n\nDEBUGBAR_ENABLED=false\n\nSAML2_SP_CERT_PRIVATEKEY=\"/home/jiminny/storage/saml.pem\"\n\nSAML2_ERROR_URL=\"/\"\nSAML2_LOGIN_URL=\"/dashboard\"\n\nSAML2_CONTACT_TECHNICAL_NAME=\"Engineering Support\"\nSAML2_CONTACT_TECHNICAL_EMAIL=\"engineers@jiminny.com\"\n\nSAML2_CONTACT_SUPPORT_NAME=\"Support\"\nSAML2_CONTACT_SUPPORT_EMAIL=\"support@jiminny.com\"\n\nSAML2_ORGANIZATION_NAME=\"Jiminny\"\nSAML2_ORGANIZATION_URL=\"https://jiminny.com\"\n\nMAXIO_API_ROUTE=https://jiminny-sandbox-two.chargify.com\nMAXIO_API_KEY=TwD5RTL6FOW3Hi25exynG2yvja3DBuG1Zyg2YTNhPDg\nMAXIO_PASSWORD=\n\nKIOSK_TEAMS=1\n\nUSERPILOT_KEY=7014b4a5b4defc7a\nUSERPILOT_TOKEN=NX-3f3884db\n\nRECALL_AI_API_URL=https://us-east-1.recall.ai/api/\nRECALL_AI_ZOOM_REGISTERED_EMAIL=notetaker@jiminny.com\n\n# System Environment Variable defined in AWS\n# RECALL_AI_API_KEY=\n# RECALL_AI_WEBHOOK_SECRET=\n# RECALL_AI_ZOOM_BOT_APP_ID=\n# ZOOM_BOT_REDIRECT_URI=\n\n# should be same accross instances\nINTERNAL_WEBHOOK_SECRET=d6e2f3d842d8c97d26d65c5a53442841dbb928a5fcfba160be7f5142fea5b322\n\nHUBSPOT_WEBHOOK_FORWARD_URLS='https://app.qa.jiminny.com,https://app.qai.jiminny.com,https://jupiter.staging.jiminny.com,https://uranus.staging.jiminny.com'","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},"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},"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},"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},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\UserPilot;\n\nuse Illuminate\\Http\\Client\\PendingRequest;\nuse Illuminate\\Support\\Facades\\Http;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nclass UserPilotClient\n{\n private const API_ENDPOINT = 'https://api.userpilot.io/v1/';\n\n private const ANALYTICS_ENDPOINT = 'https://analytex.userpilot.io/v1/';\n\n private function createRequest(): PendingRequest\n {\n return Http::withHeaders([\n 'X-API-Version' => '2020-09-22',\n 'Authorization' => 'Token ' . config('services.userpilot.key'),\n ]);\n }\n\n public function track(User $user, string $event, array $payload = []): void\n {\n if ($this->shouldRequest($user->getTeam()) === false) {\n return;\n }\n\n $this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'track', [\n 'event_name' => $event,\n 'user_id' => $user->getUuid(),\n 'metadata' => $payload,\n ]);\n }\n\n public function upsertUser(User $user): void\n {\n if ($this->shouldRequest($user->getTeam()) === false) {\n return;\n }\n\n $companyMetadata = $this->getCompanyMetadata($user->getTeam());\n $companyMetadata['id'] = $user->getTeam()->getUuid();\n\n $this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'identify', [\n 'user_id' => $user->getUuid(),\n 'metadata' => [\n 'name' => $user->name,\n 'first_name' => $user->getFirstName(),\n 'position' => $user->job ? $user->job->name : null,\n 'email' => $user->getEmailAddress(),\n 'created_at' => $user->getCreatedAt()->unix(),\n 'is_admin' => $user->hasRole(User::ROLE_ADMIN),\n 'is_manager' => $user->hasRole(User::ROLE_MANAGER),\n 'is_owner' => $user->isTeamOwner(),\n 'is_insights' => $user->hasRole(User::ROLE_ANALYST),\n 'is_recorder' => $user->hasRole(User::ROLE_RECORDER),\n 'is_jiminny_voice' => $user->hasRole(User::ROLE_RECORDER_AND_VOICE),\n 'is_listener' => $user->hasRole(User::ROLE_LISTENER),\n 'license' => null,\n 'team' => $user->group ? $user->group->name : null,\n 'language' => $user->getLanguage(),\n 'email_sync' => $user->isSyncEmailEnabled(),\n ],\n 'company' => $companyMetadata,\n ]);\n }\n\n public function upsertCompany(Team $team): void\n {\n $this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'companies/identify', [\n 'company_id' => $team->getUuid(),\n 'metadata' => $this->getCompanyMetadata($team),\n ]);\n }\n\n private function getCompanyMetadata(Team $team): array\n {\n return [\n 'created_at' => $team->getCreatedAt()->unix(),\n 'name' => $team->getName(),\n 'region' => config('jiminny.deploy_region'),\n 'crm' => $team->getCrmConfiguration()->getProviderName(),\n 'crm_installed_app_version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n 'calendar' => $team->getCalendarProvider(),\n 'notification_provider' => $team->getNotificationProvider(),\n 'has_jiminny_voice' => $team->hasFeature(FeatureEnum::DIALER),\n 'tier' => $team->getTier()?->getTitle(),\n ];\n }\n\n\n public function deleteUser(User $user): void\n {\n if ($this->shouldRequest($user->getTeam()) === false) {\n return;\n }\n\n $this->createRequest()->delete(self::API_ENDPOINT . 'users', [\n 'users' => [$user->getUuid()],\n ]);\n }\n\n public function deleteCompany(Team $team): void\n {\n if ($this->shouldRequest($team) === false) {\n return;\n }\n\n $this->createRequest()->delete(self::API_ENDPOINT . 'companies', [\n 'companies' => [$team->getUuid()],\n ]);\n }\n\n public function shouldRequest(Team $team): bool\n {\n return config('services.userpilot.key') !== null && $team->getPartnerId() === Partner::PARTNER_DEFAULT;\n }\n\n}","depth":4,"value":"<?php\n\nnamespace Jiminny\\Services\\UserPilot;\n\nuse Illuminate\\Http\\Client\\PendingRequest;\nuse Illuminate\\Support\\Facades\\Http;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nclass UserPilotClient\n{\n private const API_ENDPOINT = 'https://api.userpilot.io/v1/';\n\n private const ANALYTICS_ENDPOINT = 'https://analytex.userpilot.io/v1/';\n\n private function createRequest(): PendingRequest\n {\n return Http::withHeaders([\n 'X-API-Version' => '2020-09-22',\n 'Authorization' => 'Token ' . config('services.userpilot.key'),\n ]);\n }\n\n public function track(User $user, string $event, array $payload = []): void\n {\n if ($this->shouldRequest($user->getTeam()) === false) {\n return;\n }\n\n $this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'track', [\n 'event_name' => $event,\n 'user_id' => $user->getUuid(),\n 'metadata' => $payload,\n ]);\n }\n\n public function upsertUser(User $user): void\n {\n if ($this->shouldRequest($user->getTeam()) === false) {\n return;\n }\n\n $companyMetadata = $this->getCompanyMetadata($user->getTeam());\n $companyMetadata['id'] = $user->getTeam()->getUuid();\n\n $this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'identify', [\n 'user_id' => $user->getUuid(),\n 'metadata' => [\n 'name' => $user->name,\n 'first_name' => $user->getFirstName(),\n 'position' => $user->job ? $user->job->name : null,\n 'email' => $user->getEmailAddress(),\n 'created_at' => $user->getCreatedAt()->unix(),\n 'is_admin' => $user->hasRole(User::ROLE_ADMIN),\n 'is_manager' => $user->hasRole(User::ROLE_MANAGER),\n 'is_owner' => $user->isTeamOwner(),\n 'is_insights' => $user->hasRole(User::ROLE_ANALYST),\n 'is_recorder' => $user->hasRole(User::ROLE_RECORDER),\n 'is_jiminny_voice' => $user->hasRole(User::ROLE_RECORDER_AND_VOICE),\n 'is_listener' => $user->hasRole(User::ROLE_LISTENER),\n 'license' => null,\n 'team' => $user->group ? $user->group->name : null,\n 'language' => $user->getLanguage(),\n 'email_sync' => $user->isSyncEmailEnabled(),\n ],\n 'company' => $companyMetadata,\n ]);\n }\n\n public function upsertCompany(Team $team): void\n {\n $this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'companies/identify', [\n 'company_id' => $team->getUuid(),\n 'metadata' => $this->getCompanyMetadata($team),\n ]);\n }\n\n private function getCompanyMetadata(Team $team): array\n {\n return [\n 'created_at' => $team->getCreatedAt()->unix(),\n 'name' => $team->getName(),\n 'region' => config('jiminny.deploy_region'),\n 'crm' => $team->getCrmConfiguration()->getProviderName(),\n 'crm_installed_app_version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n 'calendar' => $team->getCalendarProvider(),\n 'notification_provider' => $team->getNotificationProvider(),\n 'has_jiminny_voice' => $team->hasFeature(FeatureEnum::DIALER),\n 'tier' => $team->getTier()?->getTitle(),\n ];\n }\n\n\n public function deleteUser(User $user): void\n {\n if ($this->shouldRequest($user->getTeam()) === false) {\n return;\n }\n\n $this->createRequest()->delete(self::API_ENDPOINT . 'users', [\n 'users' => [$user->getUuid()],\n ]);\n }\n\n public function deleteCompany(Team $team): void\n {\n if ($this->shouldRequest($team) === false) {\n return;\n }\n\n $this->createRequest()->delete(self::API_ENDPOINT . 'companies', [\n 'companies' => [$team->getUuid()],\n ]);\n }\n\n public function shouldRequest(Team $team): bool\n {\n return config('services.userpilot.key') !== null && $team->getPartnerId() === Partner::PARTNER_DEFAULT;\n }\n\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9068951185817143330
|
7801943257400207469
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20738-debug-AJ-trackin Project: faVsco.js, menu
JY-20738-debug-AJ-tracking-UP, menu
Start Listening for PHP Debug Connections
ReportControllerTest
Run 'ReportControllerTest'
Debug 'ReportControllerTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
USERPI
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
APP_ENV=staging
[ENV_SECRET]
APP_DEBUG=false
LOG_CHANNEL=errorlog
LOG_LEVEL=info
APP_URL=https://app.staging.jiminny.com
AWS_DEFAULT_REGION=us-east-2
APP_REGION=us-east-2
APP_DEPLOY_REGION=us
SECURITY_HEADER_HSTS_ENABLE=true
CONTENT_SECURITY_POLICY_REPORT_URI=https://jmny.report-uri.com/r/d/csp/reportOnly
SECURITY_HEADER_CUSTOM_CSP=null
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=jiminny
DB_USERNAME=root
[ENV_SECRET]
CASHIER_MODEL=Jiminny\Models\User
BROADCAST_DRIVER=pusher
CACHE_DRIVER=redis
CACHE_PREFIX=jmny:
SESSION_DRIVER=redis
QUEUE_CONNECTION=sqs
GITHUB_TOKEN=null
REDIS_CLIENT=phpredis
REDIS_HOST=REDIS_ENDPOINT
[ENV_SECRET]
REDIS_PORT=6379
REDIS_PREFIX=jmny_database_
SENTRY_DSN=https://3943fc8002e149d5afa2d7ae983efd7d:[EMAIL]/82419
SENTRY_DSN_CONFERENCE=https://b0caaeacb6b9448fb1b51f7244c102a4:[EMAIL]/298914
SENTRY_DSN_FRONT_END=https://[EMAIL]/5627310
LOGROCKET_CONFERENCE_ID=ponxaf/conference-staging
LOGROCKET_APP_ID=ponxaf/platform-staging
MAIL_MAILER=ses
MAIL_HOST=email-smtp.us-east-1.amazonaws.com
MAIL_PORT=2525
MAIL_USERNAME=null
[ENV_SECRET]
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=[EMAIL]
MAIL_FROM_NAME="The Jiminny Team"
MAIL_DOMAIN=staging.jiminny.com
[ENV_SECRET]
TUNNEL_HOST=https://team:[EMAIL]
OUTLOOK_URL=https://outlook.staging.jiminny.com
PUSHER_APP_ID=
[ENV_SECRET]
PUSHER_APP_CLUSTER=mt1
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
STRIPE_MODEL=Jiminny\Models\Team
[ENV_SECRET]
[ENV_SECRET]
CASHIER_ENV=testing
PLANHAT_ENABLED=
PLANHAT_TENANT_UUID=
PLANHAT_JIMINNY_JWT=
SESSION_DOMAIN=app.staging.jiminny.com
SESSION_SECURE_COOKIE=true
SESSION_COOKIE=jmny_s
SESSION_CONNECTION=session
TWILIO_ACCOUNT_SID=
[ENV_SECRET]
TWILIO_LOG_LEVEL=debug
TWILIO_ACCOUNT_SID_JIMINNY=
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
SALESFORCE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/salesforce
SALESFORCE_SCOPE="api refresh_token web"
[ENV_SECRET]
[ENV_SECRET]
HUBSPOT_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/hubspot
HUBSPOT_SCOPE="crm.lists.read crm.objects.contacts.read crm.objects.contacts.write crm.objects.companies.write crm.schemas.contacts.read crm.objects.companies.read crm.objects.deals.read crm.objects.deals.write crm.schemas.companies.read crm.schemas.deals.read crm.objects.owners.read"
HUBSPOT_JOURNAL_SCOPE="developer.webhooks_journal.read developer.webhooks_journal.subscriptions.read developer.webhooks_journal.subscriptions.write developer.webhooks_journal.snapshots.read developer.webhooks_journal.snapshots.write"
HUBSPOT_APP_ID=38483
[ENV_SECRET]
[ENV_SECRET]
PIPEDRIVE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/pipedrive
PIPEDRIVE_SCOPE="base deals:full activities:full contacts:read search:read"
[ENV_SECRET]
[ENV_SECRET]
SALESLOFT_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/salesloft
SALESLOFT_SCOPE=""
[ENV_SECRET]
[ENV_SECRET]
AIRCALL_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/aircall
AIRCALL_SCOPE="public_api"
[ENV_SECRET]
[ENV_SECRET]
RINGCENTRAL_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/ringcentral
RINGCENTRAL_SCOPE=""
# Avaya is RingCentral owned, and it re-uses the same API
AVAYA_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/avaya
# Telus is RingCentral owned, and it re-uses the same API
TELUS_REDIRECT_URI=https://app.jiminny.com/auth/callback/telus
[ENV_SECRET]
[ENV_SECRET]
DIALPAD_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/dialpad
DIALPAD_SCOPE="recordings_export"
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
LINKEDIN_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/linkedin
LINKEDIN_SCOPE=""
[ENV_SECRET]
[ENV_SECRET]
LINKEDIN_CONFERENCE_REDIRECT_URI=https://app.staging.jiminny.com/conference/callback/linkedin
LINKEDIN_CONFERENCE_SCOPE=""
[ENV_SECRET]
#[ENV_SECRET]
[ENV_SECRET]
#[ENV_SECRET]
SLACK_APP_ID=A5YBAJ8TA
#SLACK_APP_ID=A1GQA48F9
[ENV_SECRET]
#[ENV_SECRET]
SLACK_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/slack
SLACK_SCOPE="channels:read,chat:write,chat:write.public,groups:read,im:read,im:write,users:read,users:read.email,incoming-webhook"
[ENV_SECRET]
[ENV_SECRET]
GOOGLE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/google
GOOGLE_SCOPE="openid email profile"
[ENV_SECRET]
MICROSOFT_OFFICE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/office
MICROSOFT_OFFICE_SCOPE="openid profile email offline_access"
TEAMS_COMPLIANCE_BOT_HOST=https://teams-bot.staging.jiminny.com
[ENV_SECRET]
[ENV_SECRET]
OUTREACH_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/outreach
OUTREACH_SCOPE="email users.read prospects.read accounts.read calls.read calls.write profiles.read tasks.read"
OUTREACH_APP_ID=c6399204e2cd687a3c7e32c542933d2933b4b05657f30e2c6b2b12639e2519c3
BULLHORN_CLIENT_ID=29deb258-d44b-426e-a3d1-5ccfb846b07a
[ENV_SECRET]
BULLHORN_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/bullhorn
BULLHORN_SCOPE=""
# Session TTL in minutes
BULLHORN_SESSION_TTL=1440
# Heartbeat interval in seconds, 0 to disable
BULLHORN_HEARTBEAT_INTERVAL=0
# Delays in seconds for retrying request important/transactional requests, 0 to disable
BULLHORN_RETRY_DELAYS=0
# Delay in seconds before a queued retry is executed. 0 to disable
BULLHORN_QUEUE_DELAYS=0
[ENV_SECRET]
ZOOM_PHONE_REDIRECT_URI=https://app.staging.jiminny.com/auth/callback/zoom-phone
ZOOM_PHONE_SCOPE=""
ZOOM_PHONE_APP_ID=lKHL2yc0R3SDjX4b__JomA
[ENV_SECRET]
ZOOM_BOT_SCOPE=""
ZOOM_BOT_APP_ID=DHU7peAKRKG9YYOzXt_GZg
[ENV_SECRET]
## Integration.app translates multipe CRM apis for us
INTEGRATION_APP_ENABLED=true
INTEGRATION_APP_SALESFORCE_TEST_ENABLED=false
INTEGRATION_APP_URL=https://api.integration.app
[ENV_SECRET]
[ENV_SECRET]
# Talkdesk OAuth configuration
# Basic auth
TALKDESK_APP_ID=d0582f3489224089bbb31be4d54cf49e
[ENV_SECRET]
TALKDESK_SCOPE="openid refresh_token apps:read account:read users:read reports:read recordings:read contacts:read data-reports:read events:read webhooks-trigger:write"
TALKDESK_REDIRECT_URI=https://app.dev.jiminny.com/auth/callback/talkdesk
TALKDESK_SUBDOMAIN=jiminny-dev
TALKDESK_REGION=us
CHROME_WEB_STORE_EXT_ID=dccmccckblpfjiljnpephmlfhpmiamkd
[ENV_SECRET]
[ENV_SECRET]
S3_CLIENT_DATA_REGION=CLIENT_DATA_BUCKET_REGION
S3_CLIENT_DATA_BUCKET=CLIENT_DATA_BUCKET_NAME
S3_CLIENT_DATA_CLOUD_FRONT_HOST=CLIENT_DATA_BUCKET_CLOUD_FRONT_DOMAIN
S3_CLIENT_DATA_CLOUD_FRONT_URL=https://CLIENT_DATA_BUCKET_CLOUD_FRONT_DOMAIN
[ENV_SECRET]
S3_CLIENT_DATA_CLOUD_FRONT_SIGNED_COOKIE_DOMAIN='.app.staging.jiminny.com'
FILESYSTEM_DISK=client-data-cloud-ec2
FFPROBE_PATH=/usr/local/bin/ffprobe
FFMPEG_PATH=/usr/local/bin/ffmpeg
[ENV_SECRET]
[ENV_SECRET]
SQS_PREFIX=_QUEUE_PREFIX
SQS_QUEUE=_QUEUE_SQS_NAME
SQS_FIFO_QUEUE=_QUEUE_SQS_FIFO_NAME.FIFO
SQS_REGION=_QUEUE_REGION
KINESIS_AWS_REGION=us-east-2
INSIGHTS_EVENTS_AWS_KINESIS_STREAM_NAME=activity-event-insights
INSIGHTS_METRICS_AWS_KINESIS_STREAM_NAME=activity-metric-insights
GOOGLE_TEXT_RELAY_MAILBOX=catch-all
GOOGLE_TEXT_RELAY_HOST=txt.staging.jiminny.com
GOOGLE_TEXT_RELAY_TOPIC=projects/jiminny-web/topics/com.jiminny.staging.txt
GOOGLE_TEXT_RELAY_SUBSCRIPTION=projects/jiminny-web/subscriptions/staging-relay
ELASTICSEARCH_HOST=_AWS_ELASTIC_SEARCH_HOST
ELASTICSEARCH_PORT=80
ELASTICSEARCH_TRANSPORT=Http
ELASTICSEARCH_ACTIVITIES_INDEX=
CDN_URL=https://_PUBLIC_CDN_URL/
# CDN Logo URLs
LOGO_CDN_URL=https://cdn.staging.jiminny.com/img/logo_icon.png
FULL_LOGO_CDN_URL=https://cdn.staging.jiminny.com/img/full_jiminny_logo.png
[ENV_SECRET]
[ENV_SECRET]
SES_REGION=_AWS_SES_REGION
BUILD_NUMBER=_BUILD_NUMBER
BUILD_BRANCH=_BUILD_BRANCH
BUILD_COMMIT_SHA1=_BUILD_COMMIT_SHA1
SCHEDULER_LOG=/proc/1/fd/1
SILENCE_FILL=sin(10*2*PI*t)*sin(880*2*PI*t)
[ENV_SECRET]
[ENV_SECRET]
TRANSCRIPTION_PROVIDER_ASSEMBLYAI_BASEURL=https://api.assemblyai.com
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
TRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_BASEURL=https://jiminny-openai-stage.openai.azure.com/
TRANSCRIPTION_SUMMARY_PROVIDER_MICROSOFT_DEPLOYMENT=staging-deployment
[ENV_SECRET]
PROPHET_AI_WRAPPER_TIMEOUT=600
PROPHET_AI_WRAPPER_CALL_READ_TIMEOUT=15
PROPHET_AI_WRAPPER_CALL_CONNECT_TIMEOUT=15
PROPHET_AI_WRAPPER_DEAL_READ_TIMEOUT=15
PROPHET_AI_WRAPPER_DEAL_CONNECT_TIMEOUT=60
PROPHET_AI_WRAPPER_ON_DEMAND_READ_TIMEOUT=60
PROPHET_AI_WRAPPER_ON_DEMAND_CONNECT_TIMEOUT=60
# AssemblyAI
AI_PROVIDER_ASSEMBLYAI_BASEURL=https://api.assemblyai.com
MEDIA_LIVE_AWS_REGION=us-east-2
[ENV_SECRET]
MEDIA_LIVE_INPUT_SECURITY_GROUP=2044916
MEDIA_LIVE_ACCESS_ROLE=arn:aws:iam::438740370364:role/medialive-role-jiminny-app-stage-stage
[ENV_SECRET]
MEDIA_LIVE_S3_REGION=us-east-2
MEDIA_LIVE_S3_BUCKET=stage-jiminny-medialive
MEDIA_STREAMING_AWS_REGION=us-east-2
MEDIA_STREAMING_AWS_ACCOUNT_ID=438740370364
MEDIA_STREAMING_AWS_STATE_MACHINE=msi_state_machine
MEDIA_STREAMING_AWS_API_GATEWAY_ENDPOINT=https://znq8b5kgkd.execute-api.us-east-2.amazonaws.com/staging/
[ENV_SECRET]
MEDIA_STREAMING_AWS_IVS_REGION=us-east-1
[ENV_SECRET]
UPLOADER_S3_REGION=us-east-2
UPLOADER_S3_BUCKET=stage-jiminny-uploader
LIVE_FEED_ENABLED_ITEMS=scorecard,activity_play,activity_shared,nudge_generated
APP_LOCAL_URL=https://your.ngrock.url
KMS_AWS_REGION=us-east-2
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
S3_FIVE9_REGION=us-east-2
S3_FIVE9_BUCKET=stage-jiminny-five9-client-data
S3_FIVE9_POLICY_ARN=jiminny-five9-client-policy
S3_FIVE9_USERNAME_PREFIX=client-five9-
[ENV_SECRET]
LARATRUST_ENABLE_CACHE=true
DEBUGBAR_ENABLED=false
[ENV_SECRET]
SAML2_ERROR_URL="/"
SAML2_LOGIN_URL="/dashboard"
SAML2_CONTACT_TECHNICAL_NAME="Engineering Support"
SAML2_CONTACT_TECHNICAL_EMAIL="[EMAIL]"
SAML2_CONTACT_SUPPORT_NAME="Support"
SAML2_CONTACT_SUPPORT_EMAIL="[EMAIL]"
SAML2_ORGANIZATION_NAME="Jiminny"
SAML2_ORGANIZATION_URL="https://jiminny.com"
MAXIO_API_ROUTE=https://jiminny-sandbox-two.chargify.com
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
[ENV_SECRET]
RECALL_AI_API_URL=https://us-east-1.recall.ai/api/
RECALL_AI_ZOOM_REGISTERED_EMAIL=[EMAIL]
# System Environment Variable defined in AWS
# RECALL_AI_API_KEY=
# RECALL_AI_WEBHOOK_SECRET=
# RECALL_AI_ZOOM_BOT_APP_ID=
# ZOOM_BOT_REDIRECT_URI=
# should be same accross instances
[ENV_SECRET]
HUBSPOT_WEBHOOK_FORWARD_URLS='https://app.qa.jiminny.com,https://app.qai.jiminny.com,https://jupiter.staging.jiminny.com,https://uranus.staging.jiminny.com'
Sync Changes
Hide This Notification
Code changed:
Hide
7
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\UserPilot;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Jiminny\Models\Feature\FeatureEnum;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
class UserPilotClient
{
private const API_ENDPOINT = 'https://api.userpilot.io/v1/';
private const ANALYTICS_ENDPOINT = 'https://analytex.userpilot.io/v1/';
private function createRequest(): PendingRequest
{
return Http::withHeaders([
'X-API-Version' => '2020-09-22',
'Authorization' => 'Token ' . config('services.userpilot.key'),
]);
}
public function track(User $user, string $event, array $payload = []): void
{
if ($this->shouldRequest($user->getTeam()) === false) {
return;
}
$this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'track', [
'event_name' => $event,
'user_id' => $user->getUuid(),
'metadata' => $payload,
]);
}
public function upsertUser(User $user): void
{
if ($this->shouldRequest($user->getTeam()) === false) {
return;
}
$companyMetadata = $this->getCompanyMetadata($user->getTeam());
$companyMetadata['id'] = $user->getTeam()->getUuid();
$this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'identify', [
'user_id' => $user->getUuid(),
'metadata' => [
'name' => $user->name,
'first_name' => $user->getFirstName(),
'position' => $user->job ? $user->job->name : null,
'email' => $user->getEmailAddress(),
'created_at' => $user->getCreatedAt()->unix(),
'is_admin' => $user->hasRole(User::ROLE_ADMIN),
'is_manager' => $user->hasRole(User::ROLE_MANAGER),
'is_owner' => $user->isTeamOwner(),
'is_insights' => $user->hasRole(User::ROLE_ANALYST),
'is_recorder' => $user->hasRole(User::ROLE_RECORDER),
'is_jiminny_voice' => $user->hasRole(User::ROLE_RECORDER_AND_VOICE),
'is_listener' => $user->hasRole(User::ROLE_LISTENER),
'license' => null,
'team' => $user->group ? $user->group->name : null,
'language' => $user->getLanguage(),
'email_sync' => $user->isSyncEmailEnabled(),
],
'company' => $companyMetadata,
]);
}
public function upsertCompany(Team $team): void
{
$this->createRequest()->post(self::ANALYTICS_ENDPOINT . 'companies/identify', [
'company_id' => $team->getUuid(),
'metadata' => $this->getCompanyMetadata($team),
]);
}
private function getCompanyMetadata(Team $team): array
{
return [
'created_at' => $team->getCreatedAt()->unix(),
'name' => $team->getName(),
'region' => config('jiminny.deploy_region'),
'crm' => $team->getCrmConfiguration()->getProviderName(),
'crm_installed_app_version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
'calendar' => $team->getCalendarProvider(),
'notification_provider' => $team->getNotificationProvider(),
'has_jiminny_voice' => $team->hasFeature(FeatureEnum::DIALER),
'tier' => $team->getTier()?->getTitle(),
];
}
public function deleteUser(User $user): void
{
if ($this->shouldRequest($user->getTeam()) === false) {
return;
}
$this->createRequest()->delete(self::API_ENDPOINT . 'users', [
'users' => [$user->getUuid()],
]);
}
public function deleteCompany(Team $team): void
{
if ($this->shouldRequest($team) === false) {
return;
}
$this->createRequest()->delete(self::API_ENDPOINT . 'companies', [
'companies' => [$team->getUuid()],
]);
}
public function shouldRequest(Team $team): bool
{
return config('services.userpilot.key') !== null && $team->getPartnerId() === Partner::PARTNER_DEFAULT;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
|
25739
|
550
|
67
|
2026-04-15T13:01:00.340586+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-15/1776 /Users/lukas/.screenpipe/data/data/2026-04-15/1776258060340_m2.jpg...
|
Boosteroid
|
Boosteroid
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
725185668458Castle Age--Padded Archer Armor Resear 725185668458Castle Age--Padded Archer Armor ResearchComplete---Villager Created-Game Paused (P)5 Magnus Olafsson: 3236/32362 Rajyapala: 3123/31237 Maximilian of Habsburg: 3017/30176 László I: 3001/30018 Almish Yiltawar: 2914/29144 Louis VI: 2889/28893 Huascár: 2844/28441 kovaliklukas: 2572/2572BBBBBBEE...
|
NULL
|
-9068860401086316186
|
NULL
|
visual_change
|
ocr
|
NULL
|
725185668458Castle Age--Padded Archer Armor Resear 725185668458Castle Age--Padded Archer Armor ResearchComplete---Villager Created-Game Paused (P)5 Magnus Olafsson: 3236/32362 Rajyapala: 3123/31237 Maximilian of Habsburg: 3017/30176 László I: 3001/30018 Almish Yiltawar: 2914/29144 Louis VI: 2889/28893 Huascár: 2844/28441 kovaliklukas: 2572/2572BBBBBBEE...
|
25736
|
|
52558
|
1136
|
28
|
2026-04-20T07:22:02.004650+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776669722004_m1.jpg...
|
Firefox
|
fix(security): composer dependency updates – 2026- fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app — Work...
|
True
|
github.com/jiminny/app/pull/11970
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Platform Sprint 2 Q2 - Platform Team - Scrum Board Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
[SRD-6793] Les Mills activity types not pulling in - Jira
[SRD-6793] Les Mills activity types not pulling in - Jira
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
[JY-20543] AJ Reports > Tracking - Jira
[JY-20543] AJ Reports > Tracking - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
New Tab
New Tab
Product Growth Platform | Userpilot
Product Growth Platform | Userpilot
Userpilot | Events
Userpilot | Events
fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app
fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Close bookmarks (⌘B)
Bookmarks
Bookmarks
Close sidebar
Search bookmarks
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
Issues(g then i)
Pull requests
Repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (32)
Pull requests
(
32
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (28)
Security and quality
(
28
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
fix(security): composer dependency updates – 2026-04-15 #11970 Edit title
fix(security): composer dependency updates – 2026-04-15
#
11970
Edit title
Ready to merge
Ready to merge
Code
Code
Open
github-actions[bot]
github-actions[bot]
wants to merge 1 commit into
master
master
from
secfix/composer-20260415
secfix/composer-20260415
Copy head branch name to clipboard
Lines changed: 23 additions & 23 deletions
Conversation (3)
Conversation
(
3
)
Commits (1)
Commits
(
1
)
Checks (6)
Checks
(
6
)
Files changed (1)
Files changed
(
1
)
Conversation
Conversation
@github-actions
Show options
github-actions bot commented 5 days ago •
github-actions
github-actions
bot
commented
5 days ago
5 days ago
•
edited
edited
Security dependency updates — composer — 2026-04-15
Security dependency updates — composer — 2026-04-15
This PR was opened automatically by the secfix bot. For this ecosystem, one commit carries every dependency upgrade from this run; see
Fixed alerts
below.
CI run logs →
CI run logs →
Upgrade safety (changelog review)
Upgrade safety (changelog review)
Overall verdict:
Mixed
— All previously-actionable alerts were fixed as safe patch/minor bumps. Alert
#463
#463
(phpunit/phpunit) is listed under
Skipped alerts
: the patched version (12.5.22) requires a major version jump from 11.x that includes documented breaking API removals and requires manual migration.
This does not replace CI, tests, or manual smoke checks before merge.
Fixed alerts
Fixed alerts
Alert
Package
Severity
CVE
Patched version
Changelog
Notes
#457
#457
laravel/passport
high
CVE-2026-39976
CVE-2026-39976
13.7.1
releases
releases
Breaking-change risk:
none observed (patch/minor). Bumped from v13.6.0 to v13.7.4 via
composer update
.
#434
#434
google/protobuf
high
CVE-2026-6409
CVE-2026-6409
4.33.6
releases
releases
Breaking-change risk:
none observed (patch/minor). Transitive dep; bumped from v4.33.5 to v4.33.6 via
composer update
.
#425
#425
phpseclib/phpseclib
high
CVE-2026-32935
CVE-2026-32935
3.0.50
releases
releases
Breaking-change risk:
none observed (patch/minor). Bumped from 3.0.49 to 3.0.51 (also
fixes
#454).
#429
#429
league/commonmark
medium
CVE-2026-33347
CVE-2026-33347
2.8.2
releases
releases
Breaking-change risk:
none observed (patch/minor). Transitive dep (via laravel/framework); bumped from 2.8.1 to 2.8.2 via
composer update
.
#454
#454
phpseclib/phpseclib
low
CVE-2026-40194
CVE-2026-40194
3.0.51
releases
releases
Breaking-change risk:
none observed (patch/minor). Covered by bump to 3.0.51 (also
fixes
#425).
Alert
#457
#457
#434
#434
#425
#425
#429
#429
#454
#454
Package
laravel/passport
google/protobuf
phpseclib/phpseclib
league/commonmark
phpseclib/phpseclib
Severity
high
high
high
medium
low
CVE
CVE-2026-39976
CVE-2026-39976
CVE-2026-6409
CVE-2026-6409
CVE-2026-32935
CVE-2026-32935
CVE-2026-33347
CVE-2026-33347
CVE-2026-40194
CVE-2026-40194
Patched version
13.7.1
4.33.6
3.0.50
2.8.2
3.0.51
Changelog
releases
releases
releases
releases
releases
releases
releases
releases
releases
releases
Notes
Breaking-change risk:
none observed (patch/minor). Bumped from v13.6.0 to v13.7.4 via
composer update
.
Breaking-change risk:
none observed (patch/minor). Transitive dep; bumped from v4.33.5 to v4.33.6 via
composer update
.
Breaking-change risk:
none observed (patch/minor). Bumped from 3.0.49 to 3.0.51 (also
fixes
#454).
Breaking-change risk:
none observed (patch/minor). Transitive dep (via laravel/framework); bumped from 2.8.1 to 2.8.2 via
composer update
.
Breaking-change risk:
none observed (patch/minor). Covered by bump to 3.0.51 (also
fixes
#425).
Skipped alerts
Skipped alerts
Alert
Package
Severity
CVE
Patched version
Changelog
Notes
#463
#463
phpunit/phpunit
high
—
12.5.22
releases
releases
Not safe:
Major bump 11.x → 12.x. PHPUnit 12.0.0 removes multiple
TestCase
and
MockBuilder
methods (
iniSet()
,
setLocale()
,
getMockForAbstractClass()
,
getMockForTrait()
,
getObjectForTrait()
,
createTestProxy()
, etc.), drops support for doc-comment metadata (
@covers
,
@uses
annotations must be migrated to PHP 8 attributes), and removes several assert methods and CLI options. Breaking changes are fully documented — manual test-suite migration required before upgrading.
Alert
#463
#463
Package
phpunit/phpunit
Severity
high
CVE
—...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"[SRD-6793] Les Mills activity types not pulling in - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[SRD-6793] Les Mills activity types not pulling in - Jira","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20543] AJ Reports > Tracking - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20543] AJ Reports > Tracking - Jira","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Product Growth Platform | Userpilot","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Product Growth Platform | Userpilot","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Userpilot | Events","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Userpilot | Events","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"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,"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,"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,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Close bookmarks (⌘B)","depth":6,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Bookmarks","depth":5,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bookmarks","depth":6,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close sidebar","depth":6,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXTextField","text":"Search bookmarks","depth":7,"help_text":"","role_description":"search text field","subrole":"AXSearchField","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to content","depth":6,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to content","depth":7,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open menu","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Homepage (g then d)","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"jiminny","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"jiminny","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"app","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"app","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search or jump to…","depth":9,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Type","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to search","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Chat with Copilot","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"Open Copilot…","depth":9,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXMenuButton","text":"Create new...","depth":9,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Issues(g then i)","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Pull requests","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Repositories","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"You have unread notifications(g then n)","depth":9,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open user navigation menu","depth":9,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Repository navigation","depth":9,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Repository navigation","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Code","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Code","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Pull requests (32)","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pull requests","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"32","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Agents","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Agents","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Actions","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Actions","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Wiki","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Wiki","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Security and quality (28)","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Security and quality","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"28","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Insights","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Insights","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Settings","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Settings","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Important update","depth":10,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Important update","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Review this update","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Review this update","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and manage your preferences in your","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub account settings","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub account settings","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dismiss banner","depth":9,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"fix(security): composer dependency updates – 2026-04-15 #11970 Edit title","depth":13,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"fix(security): composer dependency updates – 2026-04-15","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"11970","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit title","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Ready to merge","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ready to merge","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Code","depth":13,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Code","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Open","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"github-actions[bot]","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"github-actions[bot]","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"wants to merge 1 commit into","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"master","depth":15,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"master","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"secfix/composer-20260415","depth":16,"role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"secfix/composer-20260415","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy head branch name to clipboard","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Lines changed: 23 additions & 23 deletions","depth":14,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Conversation (3)","depth":16,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Conversation","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Commits (1)","depth":16,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Commits","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Checks (6)","depth":16,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Checks","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"6","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Files changed (1)","depth":16,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Files changed","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Conversation","depth":12,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"@github-actions","depth":12,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Show options","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXHeading","text":"github-actions bot commented 5 days ago •","depth":14,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXLink","text":"github-actions","depth":16,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"github-actions","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"bot","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"commented","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"5 days ago","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"5 days ago","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"•","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"edited","depth":17,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"edited","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Security dependency updates — composer — 2026-04-15","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Security dependency updates — composer — 2026-04-15","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"This PR was opened automatically by the secfix bot. For this ecosystem, one commit carries every dependency upgrade from this run; see","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Fixed alerts","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"below.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CI run logs →","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CI run logs →","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Upgrade safety (changelog review)","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Upgrade safety (changelog review)","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Overall verdict:","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Mixed","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"— All previously-actionable alerts were fixed as safe patch/minor bumps. Alert","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#463","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#463","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(phpunit/phpunit) is listed under","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Skipped alerts","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":": the patched version (12.5.22) requires a major version jump from 11.x that includes documented breaking API removals and requires manual migration.","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"This does not replace CI, tests, or manual smoke checks before merge.","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Fixed alerts","depth":16,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Fixed alerts","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Alert","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Package","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Severity","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"CVE","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Patched version","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changelog","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Notes","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#457","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#457","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"laravel/passport","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"high","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-39976","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-39976","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"13.7.1","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Bumped from v13.6.0 to v13.7.4 via","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"composer update","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#434","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#434","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"google/protobuf","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"high","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-6409","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-6409","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4.33.6","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Transitive dep; bumped from v4.33.5 to v4.33.6 via","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"composer update","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#425","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#425","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"phpseclib/phpseclib","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"high","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-32935","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-32935","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3.0.50","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Bumped from 3.0.49 to 3.0.51 (also","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"fixes","depth":21,"bounds":{"left":0.7357639,"top":0.0,"width":0.020833334,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#454).","depth":20,"bounds":{"left":0.7565972,"top":0.0,"width":0.033333335,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#429","depth":20,"bounds":{"left":0.32256943,"top":0.04111111,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#429","depth":21,"bounds":{"left":0.32256943,"top":0.04111111,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"league/commonmark","depth":20,"bounds":{"left":0.36527777,"top":0.029444445,"width":0.06111111,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"medium","depth":20,"bounds":{"left":0.44791666,"top":0.04111111,"width":0.036111113,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-33347","depth":20,"bounds":{"left":0.5052083,"top":0.04111111,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-33347","depth":21,"bounds":{"left":0.5052083,"top":0.04111111,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2.8.2","depth":20,"bounds":{"left":0.60625,"top":0.04111111,"width":0.023611112,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"bounds":{"left":0.6666667,"top":0.04111111,"width":0.03715278,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"bounds":{"left":0.6666667,"top":0.04111111,"width":0.03715278,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"bounds":{"left":0.7357639,"top":0.0,"width":0.103472225,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Transitive dep (via laravel/framework); bumped from 2.8.1 to 2.8.2 via","depth":20,"bounds":{"left":0.7357639,"top":0.0,"width":0.10138889,"height":0.112222224},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"composer update","depth":21,"bounds":{"left":0.7392361,"top":0.11388889,"width":0.074652776,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":20,"bounds":{"left":0.81701386,"top":0.11111111,"width":0.0027777778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#454","depth":20,"bounds":{"left":0.32256943,"top":0.19555555,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#454","depth":21,"bounds":{"left":0.32256943,"top":0.19555555,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"phpseclib/phpseclib","depth":20,"bounds":{"left":0.36527777,"top":0.18388888,"width":0.046527777,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"low","depth":20,"bounds":{"left":0.44791666,"top":0.19555555,"width":0.015277778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-40194","depth":20,"bounds":{"left":0.5052083,"top":0.19555555,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-40194","depth":21,"bounds":{"left":0.5052083,"top":0.19555555,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3.0.51","depth":20,"bounds":{"left":0.60625,"top":0.19555555,"width":0.029513888,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"bounds":{"left":0.6666667,"top":0.19555555,"width":0.03715278,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"bounds":{"left":0.6666667,"top":0.19555555,"width":0.03715278,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"bounds":{"left":0.7357639,"top":0.14888889,"width":0.103472225,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Covered by bump to 3.0.51 (also","depth":20,"bounds":{"left":0.7357639,"top":0.17222223,"width":0.10138889,"height":0.06555556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"fixes","depth":21,"bounds":{"left":0.7357639,"top":0.24222222,"width":0.020833334,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#425).","depth":20,"bounds":{"left":0.7565972,"top":0.24222222,"width":0.033333335,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Alert","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#457","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#457","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#434","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#434","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#425","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#425","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#429","depth":20,"bounds":{"left":0.32256943,"top":0.04111111,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#429","depth":21,"bounds":{"left":0.32256943,"top":0.04111111,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#454","depth":20,"bounds":{"left":0.32256943,"top":0.19555555,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#454","depth":21,"bounds":{"left":0.32256943,"top":0.19555555,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Package","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"laravel/passport","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"google/protobuf","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"phpseclib/phpseclib","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"league/commonmark","depth":20,"bounds":{"left":0.36527777,"top":0.029444445,"width":0.06111111,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"phpseclib/phpseclib","depth":20,"bounds":{"left":0.36527777,"top":0.18388888,"width":0.046527777,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Severity","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"high","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"high","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"high","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"medium","depth":20,"bounds":{"left":0.44791666,"top":0.04111111,"width":0.036111113,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"low","depth":20,"bounds":{"left":0.44791666,"top":0.19555555,"width":0.015277778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"CVE","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-39976","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-39976","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-6409","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-6409","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-32935","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-32935","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-33347","depth":20,"bounds":{"left":0.5052083,"top":0.04111111,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-33347","depth":21,"bounds":{"left":0.5052083,"top":0.04111111,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"CVE-2026-40194","depth":20,"bounds":{"left":0.5052083,"top":0.19555555,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CVE-2026-40194","depth":21,"bounds":{"left":0.5052083,"top":0.19555555,"width":0.08229167,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Patched version","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"13.7.1","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"4.33.6","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3.0.50","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2.8.2","depth":20,"bounds":{"left":0.60625,"top":0.04111111,"width":0.023611112,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"3.0.51","depth":20,"bounds":{"left":0.60625,"top":0.19555555,"width":0.029513888,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changelog","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"bounds":{"left":0.6666667,"top":0.04111111,"width":0.03715278,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"bounds":{"left":0.6666667,"top":0.04111111,"width":0.03715278,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"bounds":{"left":0.6666667,"top":0.19555555,"width":0.03715278,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"bounds":{"left":0.6666667,"top":0.19555555,"width":0.03715278,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Notes","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Bumped from v13.6.0 to v13.7.4 via","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"composer update","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Transitive dep; bumped from v4.33.5 to v4.33.6 via","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"composer update","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Bumped from 3.0.49 to 3.0.51 (also","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"fixes","depth":21,"bounds":{"left":0.7357639,"top":0.0,"width":0.020833334,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#454).","depth":20,"bounds":{"left":0.7565972,"top":0.0,"width":0.033333335,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"bounds":{"left":0.7357639,"top":0.0,"width":0.103472225,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Transitive dep (via laravel/framework); bumped from 2.8.1 to 2.8.2 via","depth":20,"bounds":{"left":0.7357639,"top":0.0,"width":0.10138889,"height":0.112222224},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"composer update","depth":21,"bounds":{"left":0.7392361,"top":0.11388889,"width":0.074652776,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":20,"bounds":{"left":0.81701386,"top":0.11111111,"width":0.0027777778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Breaking-change risk:","depth":21,"bounds":{"left":0.7357639,"top":0.14888889,"width":0.103472225,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"none observed (patch/minor). Covered by bump to 3.0.51 (also","depth":20,"bounds":{"left":0.7357639,"top":0.17222223,"width":0.10138889,"height":0.06555556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"fixes","depth":21,"bounds":{"left":0.7357639,"top":0.24222222,"width":0.020833334,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"#425).","depth":20,"bounds":{"left":0.7565972,"top":0.24222222,"width":0.033333335,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Skipped alerts","depth":16,"bounds":{"left":0.31284723,"top":0.29777777,"width":0.5375,"height":0.02388889},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Skipped alerts","depth":17,"bounds":{"left":0.31284723,"top":0.29777777,"width":0.08194444,"height":0.023333333},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Alert","depth":20,"bounds":{"left":0.32291666,"top":0.3611111,"width":0.022916667,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Package","depth":20,"bounds":{"left":0.365625,"top":0.3611111,"width":0.039930556,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Severity","depth":20,"bounds":{"left":0.42465279,"top":0.3611111,"width":0.038541667,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"CVE","depth":20,"bounds":{"left":0.48194444,"top":0.3611111,"width":0.019444445,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Patched version","depth":20,"bounds":{"left":0.5208333,"top":0.34944445,"width":0.038541667,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Changelog","depth":20,"bounds":{"left":0.5784722,"top":0.3611111,"width":0.050694443,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Notes","depth":20,"bounds":{"left":0.73020834,"top":0.3611111,"width":0.027777778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#463","depth":20,"bounds":{"left":0.32256943,"top":0.5738889,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#463","depth":21,"bounds":{"left":0.32256943,"top":0.5738889,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"phpunit/phpunit","depth":20,"bounds":{"left":0.36527777,"top":0.56222224,"width":0.036805555,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"high","depth":20,"bounds":{"left":0.42465279,"top":0.5738889,"width":0.019444445,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"—","depth":20,"bounds":{"left":0.48194444,"top":0.5738889,"width":0.008333334,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"12.5.22","depth":20,"bounds":{"left":0.52013886,"top":0.5738889,"width":0.03576389,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"releases","depth":20,"bounds":{"left":0.5784722,"top":0.5738889,"width":0.0375,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"releases","depth":21,"bounds":{"left":0.5784722,"top":0.5738889,"width":0.0375,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Not safe:","depth":21,"bounds":{"left":0.6479167,"top":0.41055554,"width":0.04236111,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Major bump 11.x → 12.x. PHPUnit 12.0.0 removes multiple","depth":20,"bounds":{"left":0.6479167,"top":0.41055554,"width":0.15729167,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TestCase","depth":21,"bounds":{"left":0.6510417,"top":0.46,"width":0.039930556,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"and","depth":20,"bounds":{"left":0.6944444,"top":0.45722222,"width":0.021875,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"MockBuilder","depth":21,"bounds":{"left":0.71944445,"top":0.46,"width":0.05486111,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"methods (","depth":20,"bounds":{"left":0.6479167,"top":0.45722222,"width":0.17152777,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"iniSet()","depth":21,"bounds":{"left":0.6548611,"top":0.48333332,"width":0.039583333,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":20,"bounds":{"left":0.6979167,"top":0.48055556,"width":0.0052083335,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"setLocale()","depth":21,"bounds":{"left":0.7065972,"top":0.48333332,"width":0.05486111,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":20,"bounds":{"left":0.76458335,"top":0.48055556,"width":0.0027777778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getMockForAbstractClass()","depth":21,"bounds":{"left":0.6510417,"top":0.50666666,"width":0.12465278,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":20,"bounds":{"left":0.77881944,"top":0.5038889,"width":0.0027777778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getMockForTrait()","depth":21,"bounds":{"left":0.6510417,"top":0.53,"width":0.08472222,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":20,"bounds":{"left":0.7392361,"top":0.5272222,"width":0.0027777778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getObjectForTrait()","depth":21,"bounds":{"left":0.6510417,"top":0.55333334,"width":0.094791666,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":20,"bounds":{"left":0.74895835,"top":0.5505555,"width":0.0027777778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"createTestProxy()","depth":21,"bounds":{"left":0.6510417,"top":0.57666665,"width":0.08472222,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", etc.), drops support for doc-comment metadata (","depth":20,"bounds":{"left":0.6479167,"top":0.5738889,"width":0.1857639,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"@covers","depth":21,"bounds":{"left":0.7809028,"top":0.6,"width":0.034722224,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":20,"bounds":{"left":0.8190972,"top":0.5972222,"width":0.0027777778,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"@uses","depth":21,"bounds":{"left":0.6510417,"top":0.62333333,"width":0.025,"height":0.016111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"annotations must be migrated to PHP 8 attributes), and removes several assert methods and CLI options. Breaking changes are fully documented — manual test-suite migration required before upgrading.","depth":20,"bounds":{"left":0.6479167,"top":0.6205556,"width":0.18715277,"height":0.13555555},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Alert","depth":20,"bounds":{"left":0.32291666,"top":0.3611111,"width":0.022916667,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"#463","depth":20,"bounds":{"left":0.32256943,"top":0.5738889,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"#463","depth":21,"bounds":{"left":0.32256943,"top":0.5738889,"width":0.023958333,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Package","depth":20,"bounds":{"left":0.365625,"top":0.3611111,"width":0.039930556,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"phpunit/phpunit","depth":20,"bounds":{"left":0.36527777,"top":0.56222224,"width":0.036805555,"height":0.04222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Severity","depth":20,"bounds":{"left":0.42465279,"top":0.3611111,"width":0.038541667,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"high","depth":20,"bounds":{"left":0.42465279,"top":0.5738889,"width":0.019444445,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"CVE","depth":20,"bounds":{"left":0.48194444,"top":0.3611111,"width":0.019444445,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"—","depth":20,"bounds":{"left":0.48194444,"top":0.5738889,"width":0.008333334,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-9068811591599710792
|
-838786555984597829
|
click
|
accessibility
|
NULL
|
Platform Sprint 2 Q2 - Platform Team - Scrum Board Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
[SRD-6793] Les Mills activity types not pulling in - Jira
[SRD-6793] Les Mills activity types not pulling in - Jira
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
[JY-20543] AJ Reports > Tracking - Jira
[JY-20543] AJ Reports > Tracking - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
New Tab
New Tab
Product Growth Platform | Userpilot
Product Growth Platform | Userpilot
Userpilot | Events
Userpilot | Events
fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app
fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Close bookmarks (⌘B)
Bookmarks
Bookmarks
Close sidebar
Search bookmarks
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
Issues(g then i)
Pull requests
Repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (32)
Pull requests
(
32
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (28)
Security and quality
(
28
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
fix(security): composer dependency updates – 2026-04-15 #11970 Edit title
fix(security): composer dependency updates – 2026-04-15
#
11970
Edit title
Ready to merge
Ready to merge
Code
Code
Open
github-actions[bot]
github-actions[bot]
wants to merge 1 commit into
master
master
from
secfix/composer-20260415
secfix/composer-20260415
Copy head branch name to clipboard
Lines changed: 23 additions & 23 deletions
Conversation (3)
Conversation
(
3
)
Commits (1)
Commits
(
1
)
Checks (6)
Checks
(
6
)
Files changed (1)
Files changed
(
1
)
Conversation
Conversation
@github-actions
Show options
github-actions bot commented 5 days ago •
github-actions
github-actions
bot
commented
5 days ago
5 days ago
•
edited
edited
Security dependency updates — composer — 2026-04-15
Security dependency updates — composer — 2026-04-15
This PR was opened automatically by the secfix bot. For this ecosystem, one commit carries every dependency upgrade from this run; see
Fixed alerts
below.
CI run logs →
CI run logs →
Upgrade safety (changelog review)
Upgrade safety (changelog review)
Overall verdict:
Mixed
— All previously-actionable alerts were fixed as safe patch/minor bumps. Alert
#463
#463
(phpunit/phpunit) is listed under
Skipped alerts
: the patched version (12.5.22) requires a major version jump from 11.x that includes documented breaking API removals and requires manual migration.
This does not replace CI, tests, or manual smoke checks before merge.
Fixed alerts
Fixed alerts
Alert
Package
Severity
CVE
Patched version
Changelog
Notes
#457
#457
laravel/passport
high
CVE-2026-39976
CVE-2026-39976
13.7.1
releases
releases
Breaking-change risk:
none observed (patch/minor). Bumped from v13.6.0 to v13.7.4 via
composer update
.
#434
#434
google/protobuf
high
CVE-2026-6409
CVE-2026-6409
4.33.6
releases
releases
Breaking-change risk:
none observed (patch/minor). Transitive dep; bumped from v4.33.5 to v4.33.6 via
composer update
.
#425
#425
phpseclib/phpseclib
high
CVE-2026-32935
CVE-2026-32935
3.0.50
releases
releases
Breaking-change risk:
none observed (patch/minor). Bumped from 3.0.49 to 3.0.51 (also
fixes
#454).
#429
#429
league/commonmark
medium
CVE-2026-33347
CVE-2026-33347
2.8.2
releases
releases
Breaking-change risk:
none observed (patch/minor). Transitive dep (via laravel/framework); bumped from 2.8.1 to 2.8.2 via
composer update
.
#454
#454
phpseclib/phpseclib
low
CVE-2026-40194
CVE-2026-40194
3.0.51
releases
releases
Breaking-change risk:
none observed (patch/minor). Covered by bump to 3.0.51 (also
fixes
#425).
Alert
#457
#457
#434
#434
#425
#425
#429
#429
#454
#454
Package
laravel/passport
google/protobuf
phpseclib/phpseclib
league/commonmark
phpseclib/phpseclib
Severity
high
high
high
medium
low
CVE
CVE-2026-39976
CVE-2026-39976
CVE-2026-6409
CVE-2026-6409
CVE-2026-32935
CVE-2026-32935
CVE-2026-33347
CVE-2026-33347
CVE-2026-40194
CVE-2026-40194
Patched version
13.7.1
4.33.6
3.0.50
2.8.2
3.0.51
Changelog
releases
releases
releases
releases
releases
releases
releases
releases
releases
releases
Notes
Breaking-change risk:
none observed (patch/minor). Bumped from v13.6.0 to v13.7.4 via
composer update
.
Breaking-change risk:
none observed (patch/minor). Transitive dep; bumped from v4.33.5 to v4.33.6 via
composer update
.
Breaking-change risk:
none observed (patch/minor). Bumped from 3.0.49 to 3.0.51 (also
fixes
#454).
Breaking-change risk:
none observed (patch/minor). Transitive dep (via laravel/framework); bumped from 2.8.1 to 2.8.2 via
composer update
.
Breaking-change risk:
none observed (patch/minor). Covered by bump to 3.0.51 (also
fixes
#425).
Skipped alerts
Skipped alerts
Alert
Package
Severity
CVE
Patched version
Changelog
Notes
#463
#463
phpunit/phpunit
high
—
12.5.22
releases
releases
Not safe:
Major bump 11.x → 12.x. PHPUnit 12.0.0 removes multiple
TestCase
and
MockBuilder
methods (
iniSet()
,
setLocale()
,
getMockForAbstractClass()
,
getMockForTrait()
,
getObjectForTrait()
,
createTestProxy()
, etc.), drops support for doc-comment metadata (
@covers
,
@uses
annotations must be migrated to PHP 8 attributes), and removes several assert methods and CLI options. Breaking changes are fully documented — manual test-suite migration required before upgrading.
Alert
#463
#463
Package
phpunit/phpunit
Severity
high
CVE
—...
|
52555
|
|
53908
|
1165
|
31
|
2026-04-20T08:32:52.304260+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776673972304_m2.jpg...
|
Slack
|
Aneliya Angelova (DM) - Jiminny Inc - 2 new items Aneliya Angelova (DM) - Jiminny Inc - 2 new items - Slack...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
confusion-clinic
curiosity_lab
engineering
frontend
general
infra-changes
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Aneliya Angelova
Galya Dimitrova
Stefka Stoyanova
Stoyan Tomov
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Nikolay Nikolov
Stoyan Tanev
Vasil Vasilev
Nikolay Ivanov
Ves
Toast
Jira Cloud
Messages
Messages
Add canvas
Add canvas
Files
Files
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Aneliya Angelova
Mar 26th at 3:24:06 PM
3:24 PM
Лукаш всичко наред ли е след като качи синка на контакти и акаунти
Mar 26th at 3:24:17 PM
3:24
видях този ПР
https://github.com/jiminny/app/pull/11855
https://github.com/jiminny/app/pull/11855
Lukas Kovalik
Mar 26th at 3:24:48 PM
3:24 PM
да имаше един бъг, от последния code smell
Jump to date
Aneliya Angelova
Mar 27th at 9:33:12 AM
9:33 AM
ох ясно защо не съм видяла този проблем след като ретествах след като оправи код смела
Mar 27th at 9:33:48 AM
9:33
реално нищо не се е деплойвало - в Circle CI всичко е зелено, а нищо не се е качвало на QAI
Mar 27th at 9:34:15 AM
9:34
i realno sym si testwala s промените от преди последния къмит
Mar 27th at 9:34:19 AM
9:34
image.png
Toggle file
image.png
Lukas Kovalik
Mar 27th at 9:34:41 AM
9:34 AM
да но то беше и малко edge case
Aneliya Angelova
Mar 27th at 9:34:52 AM
9:34 AM
вчера като тръгнах да качвам на ники бранча и уж се деплойва на QAI - отварям средата и гледам че стои. Хубспот
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Mar 27th at 9:35:24 AM
9:35 AM
случва се
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Mar 27th at 9:35:30 AM
9:35
иначе май си работи
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Mar 27th at 9:35:36 AM
9:35
до сега друго не съм виждал
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Aneliya Angelova
Mar 27th at 9:35:47 AM
9:35 AM
даже не знам от кога вес е счупил деплоя и колко време все съм тествала едно и също без да се усетя че деплойването не е работело
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Mar 27th at 9:36:19 AM
9:36 AM
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
Aneliya Angelova
Apr 16th at 10:00:37 AM
10:00 AM
Лукаш кога искаш да се чуем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Apr 16th at 10:00:41 AM
10:00
за командите
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Apr 16th at 10:01:59 AM
10:01 AM
айде след 15 мин че се зарових в зохо
1 reaction, react with +1 emoji
1
Add reaction…
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Apr 16th at 10:32:21 AM
10:32 AM
ако искаш да се чуем сега
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Aneliya Angelova
Apr 16th at 10:33:10 AM
10:33 AM
да
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 16th at 10:33:31 AM
10:33 AM
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Apr 16th at 10:41:11 AM
10:41 AM
php artisan automated-reports --report-id 39
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Apr 16th at 10:41:27 AM
10:41
php artisan automated-reports:send --result-id 64
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
Aneliya Angelova...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Jiminny Inc","depth":12,"bounds":{"left":0.004654255,"top":0.06304868,"width":0.010638298,"height":0.025538707},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXRadioButton","text":"Jiminny (Staging)","depth":12,"bounds":{"left":0.004654255,"top":0.10454908,"width":0.010638298,"height":0.025538707},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Add workspaces","depth":12,"bounds":{"left":0.004654255,"top":0.14604948,"width":0.010638298,"height":0.025538707},"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.022938829,"top":0.05586592,"width":0.017287234,"height":0.054269753},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.026595745,"top":0.0933759,"width":0.009973404,"height":0.0103751},"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.022938829,"top":0.110135674,"width":0.017287234,"height":0.054269753},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.027593086,"top":0.14764565,"width":0.007978723,"height":0.0103751},"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.022938829,"top":0.16440542,"width":0.017287234,"height":0.054269753},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.025265958,"top":0.2019154,"width":0.012965426,"height":0.0103751},"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.022938829,"top":0.21867518,"width":0.017287234,"height":0.054269753},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.027925532,"top":0.25618514,"width":0.0076462766,"height":0.0103751},"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.022938829,"top":0.27294493,"width":0.017287234,"height":0.054269753},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.027260639,"top":0.3104549,"width":0.008643617,"height":0.0103751},"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.022938829,"top":0.3272147,"width":0.017287234,"height":0.054269753},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.027260639,"top":0.36472467,"width":0.008976064,"height":0.0103751},"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.056848403,"top":0.09177973,"width":0.023936171,"height":0.009577015},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.06216755,"top":0.1811652,"width":0.043882977,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"bounds":{"left":0.06216755,"top":0.20351157,"width":0.044215426,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"bounds":{"left":0.06216755,"top":0.25618514,"width":0.022273935,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"bounds":{"left":0.06216755,"top":0.27853152,"width":0.011968086,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"bounds":{"left":0.06216755,"top":0.3008779,"width":0.018284574,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"bounds":{"left":0.06216755,"top":0.32322428,"width":0.034242023,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"bounds":{"left":0.06216755,"top":0.34557062,"width":0.027593086,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"bounds":{"left":0.06216755,"top":0.367917,"width":0.025598405,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"frontend","depth":23,"bounds":{"left":0.06216755,"top":0.39026338,"width":0.018949468,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"bounds":{"left":0.06216755,"top":0.41260973,"width":0.015957447,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"infra-changes","depth":23,"bounds":{"left":0.06216755,"top":0.4349561,"width":0.029587766,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"bounds":{"left":0.06216755,"top":0.45730248,"width":0.022938829,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"bounds":{"left":0.06216755,"top":0.47964883,"width":0.034906916,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"bounds":{"left":0.06216755,"top":0.5019952,"width":0.03856383,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"bounds":{"left":0.06216755,"top":0.5243416,"width":0.01662234,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"bounds":{"left":0.06216755,"top":0.54668796,"width":0.01761968,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"bounds":{"left":0.06216755,"top":0.56903434,"width":0.024268618,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"bounds":{"left":0.06216755,"top":0.5913807,"width":0.016954787,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"bounds":{"left":0.06216755,"top":0.61372703,"width":0.024268618,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"bounds":{"left":0.06216755,"top":0.6360734,"width":0.04488032,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.06216755,"top":0.688747,"width":0.03756649,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"bounds":{"left":0.06216755,"top":0.71109337,"width":0.034906916,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":23,"bounds":{"left":0.06216755,"top":0.73343974,"width":0.03756649,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tomov","depth":23,"bounds":{"left":0.06216755,"top":0.7557861,"width":0.030585106,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.06216755,"top":0.7781325,"width":0.03756649,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"bounds":{"left":0.09940159,"top":0.7781325,"width":0.0063164895,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"bounds":{"left":0.10206117,"top":0.7781325,"width":0.014295213,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"bounds":{"left":0.116023935,"top":0.79569036,"width":0.0003324468,"height":0.0007980846},"role_description":"text"},{"role":"AXStaticText","text":"Steliyan Georgiev","depth":23,"bounds":{"left":0.116023935,"top":0.79569036,"width":0.0003324468,"height":0.0007980846},"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Nikolov","depth":23,"bounds":{"left":0.06216755,"top":0.8004789,"width":0.034242023,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"bounds":{"left":0.06216755,"top":0.8228252,"width":0.028922873,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"bounds":{"left":0.06216755,"top":0.8451716,"width":0.026263298,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Ivanov","depth":23,"bounds":{"left":0.06216755,"top":0.86751795,"width":0.031914894,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Ves","depth":23,"bounds":{"left":0.06216755,"top":0.8898643,"width":0.0076462766,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"bounds":{"left":0.06216755,"top":0.9425379,"width":0.011968086,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"bounds":{"left":0.06216755,"top":0.9648843,"width":0.021609042,"height":0.014365523},"role_description":"text"},{"role":"AXRadioButton","text":"Messages","depth":17,"bounds":{"left":0.12200798,"top":0.09177973,"width":0.030585106,"height":0.030327214},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Messages","depth":19,"bounds":{"left":0.13131648,"top":0.10055866,"width":0.01861702,"height":0.012769354},"role_description":"text"},{"role":"AXRadioButton","text":"Add canvas","depth":18,"bounds":{"left":0.15392287,"top":0.09177973,"width":0.033909574,"height":0.030327214},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Add canvas","depth":20,"bounds":{"left":0.16323139,"top":0.10055866,"width":0.021941489,"height":0.012769354},"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":17,"bounds":{"left":0.18916224,"top":0.09177973,"width":0.020944148,"height":0.030327214},"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":19,"bounds":{"left":0.19847074,"top":0.10055866,"width":0.008976064,"height":0.012769354},"role_description":"text"},{"role":"AXPopUpButton","text":"Add and Edit Channel Tabs","depth":17,"bounds":{"left":0.21110372,"top":0.09177973,"width":0.010970744,"height":0.030327214},"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Canvas","depth":17,"bounds":{"left":0.11635638,"top":0.0518755,"width":0.015625,"height":0.0007980846},"role_description":"text"},{"role":"AXStaticText","text":"List","depth":17,"bounds":{"left":0.11635638,"top":0.0518755,"width":0.0076462766,"height":0.0007980846},"role_description":"text"},{"role":"AXStaticText","text":"Folder","depth":17,"bounds":{"left":0.11635638,"top":0.0518755,"width":0.013962766,"height":0.0007980846},"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":22,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Aneliya Angelova","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"role_description":"text"},{"role":"AXLink","text":"Mar 26th at 3:24:06 PM","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3:24 PM","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"Лукаш всичко наред ли е след като качи синка на контакти и акаунти","depth":24,"role_description":"text"},{"role":"AXLink","text":"Mar 26th at 3:24:17 PM","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3:24","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"видях този ПР","depth":24,"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/app/pull/11855","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/app/pull/11855","depth":25,"role_description":"text"},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"role_description":"text"},{"role":"AXLink","text":"Mar 26th at 3:24:48 PM","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3:24 PM","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"да имаше един бъг, от последния code smell","depth":24,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":22,"bounds":{"left":0.28257978,"top":0.12689546,"width":0.049867023,"height":0.022346368},"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Aneliya Angelova","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:33:12 AM","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:33 AM","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"ох ясно защо не съм видяла този проблем след като ретествах след като оправи код смела","depth":24,"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:33:48 AM","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:33","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"реално нищо не се е деплойвало - в Circle CI всичко е зелено, а нищо не се е качвало на QAI","depth":24,"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:34:15 AM","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:34","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"i realno sym si testwala s промените от преди последния къмит","depth":24,"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:34:19 AM","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:34","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"image.png","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"","depth":24,"role_description":"text"},{"role":"AXButton","text":"Toggle file","depth":24,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"image.png","depth":26,"role_description":"Unlabelled image","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:34:41 AM","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:34 AM","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"да но то беше и малко edge case","depth":24,"role_description":"text"},{"role":"AXButton","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.13796543,"top":0.121308856,"width":0.038896278,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.17652926,"top":0.12290503,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:34:52 AM","depth":23,"bounds":{"left":0.17918883,"top":0.12529927,"width":0.015292553,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:34 AM","depth":24,"bounds":{"left":0.17918883,"top":0.12529927,"width":0.015292553,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"вчера като тръгнах да качвам на ники бранча и уж се деплойва на QAI - отварям средата и гледам че стои. Хубспот","depth":24,"bounds":{"left":0.13796543,"top":0.14046289,"width":0.26662233,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.13796543,"top":0.16280925,"width":0.030917553,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.16855054,"top":0.16440542,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:35:24 AM","depth":23,"bounds":{"left":0.17121011,"top":0.16679968,"width":0.015292553,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:35 AM","depth":24,"bounds":{"left":0.17121011,"top":0.16679968,"width":0.015292553,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"случва се","depth":24,"bounds":{"left":0.13796543,"top":0.1819633,"width":0.021609042,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Mar 27th at 9:35:30 AM","depth":24,"bounds":{"left":0.12732713,"top":0.20830008,"width":0.007978723,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:35","depth":25,"bounds":{"left":0.12732713,"top":0.20830008,"width":0.007978723,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"иначе май си работи","depth":24,"bounds":{"left":0.13796543,"top":0.20590582,"width":0.047539894,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Mar 27th at 9:35:36 AM","depth":24,"bounds":{"left":0.12732713,"top":0.23224261,"width":0.007978723,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:35","depth":25,"bounds":{"left":0.12732713,"top":0.23224261,"width":0.007978723,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"до сега друго не съм виждал","depth":24,"bounds":{"left":0.13796543,"top":0.22984837,"width":0.066821806,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.13796543,"top":0.25219473,"width":0.038896278,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.17652926,"top":0.25379092,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:35:47 AM","depth":23,"bounds":{"left":0.17918883,"top":0.25618514,"width":0.015292553,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:35 AM","depth":24,"bounds":{"left":0.17918883,"top":0.25618514,"width":0.015292553,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"даже не знам от кога вес е счупил деплоя и колко време все съм тествала едно и също без да се усетя че деплойването не е работело","depth":24,"bounds":{"left":0.13796543,"top":0.27134877,"width":0.31150267,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.13796543,"top":0.29369512,"width":0.030917553,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.16855054,"top":0.2952913,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Mar 27th at 9:36:19 AM","depth":23,"bounds":{"left":0.17121011,"top":0.29768556,"width":0.015292553,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:36 AM","depth":24,"bounds":{"left":0.17121011,"top":0.29768556,"width":0.015292553,"height":0.011173184},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Jump to date","depth":22,"bounds":{"left":0.28125,"top":0.35115722,"width":0.052526597,"height":0.022346368},"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.13796543,"top":0.38228253,"width":0.038896278,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.17652926,"top":0.38387868,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Apr 16th at 10:00:37 AM","depth":23,"bounds":{"left":0.17918883,"top":0.38627294,"width":0.01761968,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10:00 AM","depth":24,"bounds":{"left":0.17918883,"top":0.38627294,"width":0.01761968,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"Лукаш кога искаш да се чуем","depth":24,"bounds":{"left":0.13796543,"top":0.40143654,"width":0.06781915,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Apr 16th at 10:00:41 AM","depth":24,"bounds":{"left":0.125,"top":0.42777336,"width":0.010305851,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10:00","depth":25,"bounds":{"left":0.125,"top":0.42777336,"width":0.010305851,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"за командите","depth":24,"bounds":{"left":0.13796543,"top":0.4253791,"width":0.03125,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.13796543,"top":0.44772545,"width":0.030917553,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.16855054,"top":0.44932163,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Apr 16th at 10:01:59 AM","depth":23,"bounds":{"left":0.17121011,"top":0.4517159,"width":0.01761968,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10:01 AM","depth":24,"bounds":{"left":0.17121011,"top":0.4517159,"width":0.01761968,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"айде след 15 мин че се зарових в зохо","depth":24,"bounds":{"left":0.13796543,"top":0.4668795,"width":0.08809841,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"1 reaction, react with +1 emoji","depth":24,"bounds":{"left":0.13796543,"top":0.48603353,"width":0.014295213,"height":0.01915403},"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":25,"bounds":{"left":0.14727394,"top":0.48922586,"width":0.0023271276,"height":0.011971269},"role_description":"text"},{"role":"AXButton","text":"Add reaction…","depth":24,"bounds":{"left":0.15325798,"top":0.48603353,"width":0.011635638,"height":0.01915403},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.13796543,"top":0.51476455,"width":0.030917553,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.16855054,"top":0.51636076,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Apr 16th at 10:32:21 AM","depth":23,"bounds":{"left":0.17121011,"top":0.51875496,"width":0.01761968,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10:32 AM","depth":24,"bounds":{"left":0.17121011,"top":0.51875496,"width":0.01761968,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"ако искаш да се чуем сега","depth":24,"bounds":{"left":0.13796543,"top":0.5339186,"width":0.059840426,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"bounds":{"left":0.40625,"top":0.5019952,"width":0.010638298,"height":0.025538707},"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"bounds":{"left":0.4168883,"top":0.5019952,"width":0.010638298,"height":0.025538707},"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"bounds":{"left":0.4275266,"top":0.5019952,"width":0.010638298,"height":0.025538707},"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"bounds":{"left":0.4381649,"top":0.5019952,"width":0.010638298,"height":0.025538707},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"bounds":{"left":0.4488032,"top":0.5019952,"width":0.010638298,"height":0.025538707},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"bounds":{"left":0.45944148,"top":0.5019952,"width":0.010638298,"height":0.025538707},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"bounds":{"left":0.47007978,"top":0.5019952,"width":0.010638298,"height":0.025538707},"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"bounds":{"left":0.48071808,"top":0.5019952,"width":0.010638298,"height":0.025538707},"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.13796543,"top":0.55626494,"width":0.038896278,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.17652926,"top":0.55786115,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Apr 16th at 10:33:10 AM","depth":23,"bounds":{"left":0.17918883,"top":0.5602554,"width":0.01761968,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10:33 AM","depth":24,"bounds":{"left":0.17918883,"top":0.5602554,"width":0.01761968,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"да","depth":24,"bounds":{"left":0.13796543,"top":0.575419,"width":0.0056515955,"height":0.014365523},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A huddle happened","depth":23,"bounds":{"left":0.13796543,"top":0.59936154,"width":0.043218084,"height":0.014365523},"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.18118352,"top":0.59936154,"width":0.0026595744,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Apr 16th at 10:33:31 AM","depth":23,"bounds":{"left":0.18384309,"top":0.6017558,"width":0.01761968,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10:33 AM","depth":24,"bounds":{"left":0.18384309,"top":0.6017558,"width":0.01761968,"height":0.011173184},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":23,"bounds":{"left":0.13796543,"top":0.6392658,"width":0.030917553,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":23,"bounds":{"left":0.16855054,"top":0.6408619,"width":0.0029920214,"height":0.014365523},"role_description":"text"},{"role":"AXLink","text":"Apr 16th at 10:41:11 AM","depth":23,"bounds":{"left":0.17121011,"top":0.6432562,"width":0.01761968,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10:41 AM","depth":24,"bounds":{"left":0.17121011,"top":0.6432562,"width":0.01761968,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"php artisan automated-reports --report-id 39","depth":24,"bounds":{"left":0.14095744,"top":0.6679968,"width":0.10538564,"height":0.011971269},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Apr 16th at 10:41:27 AM","depth":24,"bounds":{"left":0.125,"top":0.70231444,"width":0.010305851,"height":0.011173184},"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"10:41","depth":25,"bounds":{"left":0.125,"top":0.70231444,"width":0.010305851,"height":0.011173184},"role_description":"text"},{"role":"AXStaticText","text":"php artisan automated-reports:send --result-id 64","depth":24,"bounds":{"left":0.14095744,"top":0.7094972,"width":0.11735372,"height":0.011971269},"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":25,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":25,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Jump to date","depth":22,"bounds":{"left":0.29488033,"top":0.74780524,"width":0.025265958,"height":0.022346368},"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.13796543,"top":0.77893054,"width":0.038896278,"height":0.017557861},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9068615172340511590
|
-1280405625529776034
|
click
|
hybrid
|
NULL
|
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Jiminny Inc
Jiminny (Staging)
Add workspaces
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
confusion-clinic
curiosity_lab
engineering
frontend
general
infra-changes
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Aneliya Angelova
Galya Dimitrova
Stefka Stoyanova
Stoyan Tomov
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Nikolay Nikolov
Stoyan Tanev
Vasil Vasilev
Nikolay Ivanov
Ves
Toast
Jira Cloud
Messages
Messages
Add canvas
Add canvas
Files
Files
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Aneliya Angelova
Mar 26th at 3:24:06 PM
3:24 PM
Лукаш всичко наред ли е след като качи синка на контакти и акаунти
Mar 26th at 3:24:17 PM
3:24
видях този ПР
https://github.com/jiminny/app/pull/11855
https://github.com/jiminny/app/pull/11855
Lukas Kovalik
Mar 26th at 3:24:48 PM
3:24 PM
да имаше един бъг, от последния code smell
Jump to date
Aneliya Angelova
Mar 27th at 9:33:12 AM
9:33 AM
ох ясно защо не съм видяла този проблем след като ретествах след като оправи код смела
Mar 27th at 9:33:48 AM
9:33
реално нищо не се е деплойвало - в Circle CI всичко е зелено, а нищо не се е качвало на QAI
Mar 27th at 9:34:15 AM
9:34
i realno sym si testwala s промените от преди последния къмит
Mar 27th at 9:34:19 AM
9:34
image.png
Toggle file
image.png
Lukas Kovalik
Mar 27th at 9:34:41 AM
9:34 AM
да но то беше и малко edge case
Aneliya Angelova
Mar 27th at 9:34:52 AM
9:34 AM
вчера като тръгнах да качвам на ники бранча и уж се деплойва на QAI - отварям средата и гледам че стои. Хубспот
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Mar 27th at 9:35:24 AM
9:35 AM
случва се
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Mar 27th at 9:35:30 AM
9:35
иначе май си работи
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Mar 27th at 9:35:36 AM
9:35
до сега друго не съм виждал
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Aneliya Angelova
Mar 27th at 9:35:47 AM
9:35 AM
даже не знам от кога вес е счупил деплоя и колко време все съм тествала едно и също без да се усетя че деплойването не е работело
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Mar 27th at 9:36:19 AM
9:36 AM
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
Aneliya Angelova
Apr 16th at 10:00:37 AM
10:00 AM
Лукаш кога искаш да се чуем
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Apr 16th at 10:00:41 AM
10:00
за командите
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Apr 16th at 10:01:59 AM
10:01 AM
айде след 15 мин че се зарових в зохо
1 reaction, react with +1 emoji
1
Add reaction…
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Apr 16th at 10:32:21 AM
10:32 AM
ако искаш да се чуем сега
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Aneliya Angelova
Apr 16th at 10:33:10 AM
10:33 AM
да
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
A huddle happened
Apr 16th at 10:33:31 AM
10:33 AM
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Apr 16th at 10:41:11 AM
10:41 AM
php artisan automated-reports --report-id 39
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Apr 16th at 10:41:27 AM
10:41
php artisan automated-reports:send --result-id 64
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
Aneliya Angelova
Mon ZU AOr 11.34:0Security patch review and testing guidanceActivityJiminny ...181 Directone:Eh External connechons.# Starred8 jiminny-x-integrati...• platform-inner-team® Channels• al-chaoter# alerts# backend* confusion-clinid# curiosity_lab# engineering# frontend# general# infra-changes# jiminny-bgi nlatform-ackets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...6 Direct messagesP. Aneliya Angelova®. Galya Dimitrova2 Stefka StovanovalR. Stoyan TomovB Aneliya Angelova,..Nikolav Nikolov&. Stoyan TanevVasil Vasilev. Nikolay IvanovP. Vesi: Apps• Toastf Jira Cloud¿ 8. Aneliya Angelova• Messagest Add canvasur FilesAneliya Angelova 9:34 AMкато тоыгнах да качвам на ники оранча и уж се деплоива па иміfriday, March p7t редата и гледам че стои. ХубспотLukas Kovalik 9:35 AMслучва сеиначе май си раоотидо сега друго не сьм виждалAneliya Angelova 9:35 AMдаже не знам от кога вес е счупил деплоя и колко време все съм тествала едно и сьщо без да се усетя че деплойването не е работелоLukas Kovallik 9:36 AMIThursday, April 16throutendoiktra telt /hAptokins aprep-mroot@docker_lamp_1:/home/jiminny# grep-rn"[EMAIL]-ahomo/inan app/Confirmed - nothing uses it. The custom trait is completely orphaned.Summary of the full review:• Security PR is safe to stay mergedAsporE/cerety hins lorawepasgieto Becaet gocha om both ker andPartner• The PlaylistPolicyTest failure is a pre-existing flaky factory seed collision, unrelatedto the PR• app/Traits/HasApiTokens. php is dead code — worth a cleanup ticket to delete itYou're good.Aneliya Angelova 10:00 AMЛукаш кога искаш да се чуемLukas Kovallk 10.01 AMайде след 15 мин че се зарових в зохоd1Lukas Kovalik 10:32 AMако искаш да се чуем сегаAneliya Angelova 10:33 AMA huddle happened 10:33 AMIukac Kovalik 10-41 AMphp artisan automated-reports --report-id 39php artisan automated-reports:send --result-id 64Aneliya Angelova 9:41 AMЗдрасти Лукаш, струва ми се, че репортите се генерират върху всички активитита със съответния Saved search bez da e dobawen filtyr za dataLukas Kovalik 10:31AMАни след малко може да се чуемAneliya Angelova 11:27 AMнапnaво званни когато можецMessage Aneliva Angelove+ Дạ.Reply...Sonnet 4.6Claude is Al and can make mistakes. Please double-check responses....
|
53906
|
|
58567
|
1258
|
48
|
2026-04-20T12:52:22.515927+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776689542515_m1.jpg...
|
PhpStorm
|
PhpStorm
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=0Nikolay Nikolov (Presenting, annotating)100% K478• Mon 20 Apr 15:52:2200 7• а 8+Mon 20 Apr 16:622 (JY-20615) Notity4-20613) AliowZ (JY-20500) BatchZ (JY-20501) Batchi x00t3205 - Retneees Jminny - CalendalD Meet - [Plattorhttps://iminny.atlassian.net/browse/JY-205007at/OriginseyJpfjolNJVmYzgxZjYzYTFhNDNjYTg4ZJYOYTZIZGZ/MmViZDUILCJwljolamlyYS1zbGFjay1pbnQifQh LuM Leaderidoard.C TODOC Debug crm-sync..- KelneC Sentry C ES C LearnL Al chapter• Home |L Al Bookmarxs88Q SearchJiminny (New) / JY-15971 / R JY-20500A JY-20200 Batch update deals, leads, accounts and contacts for SalesforceDEPLOYEDIs cloned by@ JY-20501 Batch initial sync for HubspotBACKLOG• Activity|HistoryWork 10gNormal textAv.@@⅘+v0.Optimize initial sync when adding user profile.9. 8 + Stefka Stoyanova 2 + Galya Dimitrova& + Nikolay Yankov + Aneliya Angelova + Mario Georgiev + Lukas Kova <Cancel35 minutes aoo (edited)Salesforce Batch Sync ArchitectureFlow:1. Syncobjects job → dispatches FetchSalesforceEntitiesJob for each entity type (lead, account, contact, opportunity, contact_role)2. FetchSalesfozceEntitiesJob:• Streams records from Salesforce API|l meet.google.com is sharing your screen.Stoo sharingGroups into batches of 100 recordsHisnkevien© Rev Prophet( My PRs+ CreateAsk Rovov DetailsFix versionsNoneSprintPlatform Sprint 3 Q2DaysNoneNeed QANoParent→ JY-15971 CRM Synching ImprovemiCanny LinksOpen Canny LinksMore fieldsOriginal estimateohTime trackingNo time logged> Automation 4 Rule executions› featureOS Open featureOs› Sentry sll Unked IssuesCreated 24 March 2026 at 17:57wowedowe?woaetheAneliya AngelovaStefka Stoyanova2 othersNikblay Nikolov3:52 PM | [Platform] Refinement ®..•Lukas Kovalik50:34...
|
NULL
|
-9068447833891406744
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=0Nikolay Nikolov (Presenting, annotating)100% K478• Mon 20 Apr 15:52:2200 7• а 8+Mon 20 Apr 16:622 (JY-20615) Notity4-20613) AliowZ (JY-20500) BatchZ (JY-20501) Batchi x00t3205 - Retneees Jminny - CalendalD Meet - [Plattorhttps://iminny.atlassian.net/browse/JY-205007at/OriginseyJpfjolNJVmYzgxZjYzYTFhNDNjYTg4ZJYOYTZIZGZ/MmViZDUILCJwljolamlyYS1zbGFjay1pbnQifQh LuM Leaderidoard.C TODOC Debug crm-sync..- KelneC Sentry C ES C LearnL Al chapter• Home |L Al Bookmarxs88Q SearchJiminny (New) / JY-15971 / R JY-20500A JY-20200 Batch update deals, leads, accounts and contacts for SalesforceDEPLOYEDIs cloned by@ JY-20501 Batch initial sync for HubspotBACKLOG• Activity|HistoryWork 10gNormal textAv.@@⅘+v0.Optimize initial sync when adding user profile.9. 8 + Stefka Stoyanova 2 + Galya Dimitrova& + Nikolay Yankov + Aneliya Angelova + Mario Georgiev + Lukas Kova <Cancel35 minutes aoo (edited)Salesforce Batch Sync ArchitectureFlow:1. Syncobjects job → dispatches FetchSalesforceEntitiesJob for each entity type (lead, account, contact, opportunity, contact_role)2. FetchSalesfozceEntitiesJob:• Streams records from Salesforce API|l meet.google.com is sharing your screen.Stoo sharingGroups into batches of 100 recordsHisnkevien© Rev Prophet( My PRs+ CreateAsk Rovov DetailsFix versionsNoneSprintPlatform Sprint 3 Q2DaysNoneNeed QANoParent→ JY-15971 CRM Synching ImprovemiCanny LinksOpen Canny LinksMore fieldsOriginal estimateohTime trackingNo time logged> Automation 4 Rule executions› featureOS Open featureOs› Sentry sll Unked IssuesCreated 24 March 2026 at 17:57wowedowe?woaetheAneliya AngelovaStefka Stoyanova2 othersNikblay Nikolov3:52 PM | [Platform] Refinement ®..•Lukas Kovalik50:34...
|
58565
|
|
52971
|
1149
|
25
|
2026-04-20T07:50:48.360939+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776671448360_m2.jpg...
|
Firefox
|
Application Performance Monitoring & Error Tra Application Performance Monitoring & Error Tracking Software | Sentry — Work...
|
True
|
sentry.io/welcome/
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Platform Sprint 2 Q2 - Platform Team - Scrum Board Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
[SRD-6793] Les Mills activity types not pulling in - Jira
[SRD-6793] Les Mills activity types not pulling in - Jira
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
[JY-20543] AJ Reports > Tracking - Jira
[JY-20543] AJ Reports > Tracking - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
New Tab
New Tab
Product Growth Platform | Userpilot
Product Growth Platform | Userpilot
Userpilot | Events
Userpilot | Events
fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app
fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Application Performance Monitoring & Error Tracking Software | Sentry
Application Performance Monitoring & Error Tracking Software | Sentry
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Close bookmarks (⌘B)
Bookmarks
Bookmarks
Close sidebar
Search bookmarks
Skip to main content
Skip to main content
Welcome Page
PLATFORM
PLATFORM
SOLUTIONS
SOLUTIONS
RESOURCES
RESOURCES
DOCS
DOCS
PRICING
PRICING
SIGN IN
SIGN IN
GET DEMO
GET DEMO
GET STARTED
GET STARTED
Marketing Mode ONOFF
Marketing Mode
ONOFF
ON
OFF
Want to connect with the folks building Sentry? Join us on Discord. ➔
Want to connect with the folks building Sentry? Join us on Discord. ➔
Code breaks, fix it faster
Code
breaks,
fix it faster
Application monitoring software considered
"not bad" by millions of developers.
GET STARTED
GET STARTED
SEE HOW IN SANDBOX
SEE HOW IN SANDBOX
GitHub
Disney
Atlassian
Linear
Vercel
Cloudflare
Slack
Metronome
Autodesk
Microsoft
Instacart
Lyft
Bolt
Monday
Cursor
Anthropic
Factory AI
Sentry
Baseten
Runlayer
Convex
Developer first. Always.
Developer first.
Always.
Monitor in five lines
Monitor in five lines
Drop in the
SDK
SDK
. No agents to install. No performance surprises.
Automatically root-cause any issue
Automatically root-cause any issue
Catch critical issues before you merge and fix them when they hit prod with
Seer, our debugging agent and code reviewer
Seer, our debugging agent and code reviewer
.
Break production less
Break production less
Predict and prevent the errors that matter most before they even make it to production with
AI code review
AI code review
.
Stay in the flow
Stay in the flow
From
GitHub
GitHub
,
Slack
Slack
,
Jira
Jira
, and
Linear
Linear
to coding agents with
Sentry's MCP server
Sentry's MCP server
, Sentry brings full context to every fix from dev to prod.
Everything’s connected
Everything’s
connected
Yeah, other tools exist. But
errors
errors
,
logs
logs
,
replays
replays
,
spans
spans
,
profiles
profiles
, and metrics — all connected by the same trace? That’s kind of our thing.
Go from Issue → Context → Fix.
Go from Issue → Context → Fix.
Debug 500's, trace slow requests, replay fetch() failures, and fix the broken code that caused it.
Catch slow queries, N+1s, and request timeouts before the ‘why is this so slow?’ posts fill up your feed.
Map every incident to the release, PR, and owner -- automatically.
Debugging needs context—
with or without AI
Debugging needs context—
with or without AI
Seer
Seer
, our AI debugger, uses Sentry context – logs, commits, traces, stack trace - so you can stop guessing and
it
can fix issues for you.
Analyzes every signal to explain why your code failed, not just where.
Fixes what’s broken while you ship what’s next – generating precise, merge-ready patches.
Stops bad code before it starts bad days. Correlating PRs against real error and performance history to catch regressions before they ship.
Loved by developers worldwide
Loved by developers
worldwide
We wouldn’t have scaled without Sentry. Most of our incidents are hardware-related—and we debug them all inside Sentry
Nova DasSarma
Systems Lead, Anthropic
Sentry’s high-quality tooling helps Disney+ maintain high-quality service to its tens of millions of global subscribers.
Andrew Hay
Director at Disney Streaming Services, Disney+
The signal we get from Sentry is the most reliable indicator of software issues and is used throughout Instacart because it can be easily configured for each service regardless of the language or framework.
Igor Dobrovitski
Infrastructure Software Engineer, Instacart
Get started in minutes
Get started
in minutes
Five lines of code. That's it. No complex setup, no performance hits, no waiting around.
See -- it's really just one command.
Click to Copy
npx @sentry/wizard@latest
-i
nextjs
Get started with just one line of code:
Click to Copy
npx @sentry/wizard@latest
-i
angular
Just run this command to sign up for and install Sentry.
Click to Copy
brew
install
getsentry/tools/sentry-wizard
&&
sentry-wizard
-i
android
Signup and install Sentry with just one line of code:
Click to Copy
brew
install
getsentry/tools/sentry-wizard
&&
sentry-wizard
-i
ios
Sign-up and install Sentry with just one line of code:
Click to Copy
npx @sentry/wizard@latest
-i
flutter
Install Sentry with one line of code:
Click to Copy
npx @sentry/wizard@latest
-i
reactNative
Add the Sentry dependency to your .NET MAUI application:
Click to Copy
dotnet
add
package Sentry.Maui
-v
5.11
.1
Grab the
Sentry Python SDK
Sentry Python SDK
:
Click to Copy
pip
install
--upgrade
sentry-sdk
Configure your DSN:
Click to Copy
import
sentry_sdk
sentry_sdk
.
init
(
"[URL_WITH_CREDENTIALS]
Configure your SDK:
Click to Copy
const
Sentry
=
require
(
'@sentry/node'
)
;
Sentry
.
init
(
{
dsn
:
'[URL_WITH_CREDENTIALS]
We recommend putting the Sentry initialization code into its own file and including that file as the first import in your application entry point as shown in the example below:
Click to Copy
import
{
useEffect
}
from
"react"
;
import
*
as
Sentry
from
"@sentry/react"
;
Sentry
.
init
(
{
dsn
:
"[URL_WITH_CREDENTIALS]
;
// When configuring for the first time, to see what the SDK is doing:
o
.
Debug
=
true
;
// Set TracesSampleRate to 1.0 to capture 100% of transactions for Tracing.
// We recommend adjusting this value in production.
o
.
TracesSampleRate
=
1.0
;
}
)
)
{
// App code goes here - Disposing will flush events out
}
Grab the
Sentry Go SDK
Sentry Go SDK
:
Click to Copy
go
get
"github.com/getsentry/sentry-go"
Configuration should happen as early as possible in your application's lifecycle:
Click to Copy
package
main
import
(
"log"
"time"
"github.com/getsentry/sentry-go"
)
func
main
(
)
{
err
:=
sentry
.
Init
(
sentry
.
ClientOptions
{
Dsn
:
"https://<key>@sentry.io/<project>"
,
EnableTracing
:
true
,
// Specify a fixed sample rate:
// We recommend adjusting this value in production
TracesSampleRate
:
1.0
,
// Or provide a custom sample rate:
TracesSampler
:
sentry
.
TracesSampler
(
func
(
ctx sentry
.
SamplingContext
)
float64
{
// As an example, this does not send some
// transactions to Sentry based on their name.
if
ctx
.
Span
.
Name
==
"GET /health"
{
return
0.0
}
return
1.0
}
)
,
}...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira","depth":4,"bounds":{"left":0.0018284575,"top":0.0518755,"width":0.07596409,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"[SRD-6793] Les Mills activity types not pulling in - Jira","depth":4,"bounds":{"left":0.0,"top":0.09497207,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[SRD-6793] Les Mills activity types not pulling in - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.10614525,"width":0.09524601,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.12769353,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.13886672,"width":0.19963431,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.16041501,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.17158818,"width":0.15525267,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20543] AJ Reports > Tracking - Jira","depth":4,"bounds":{"left":0.0,"top":0.19313647,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20543] AJ Reports > Tracking - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.20430966,"width":0.06981383,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira","depth":4,"bounds":{"left":0.0,"top":0.22585794,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.23703113,"width":0.10688165,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2585794,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.2697526,"width":0.12915559,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.29130086,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"New Tab","depth":5,"bounds":{"left":0.013297873,"top":0.30247405,"width":0.014960106,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Product Growth Platform | Userpilot","depth":4,"bounds":{"left":0.0,"top":0.32402235,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Product Growth Platform | Userpilot","depth":5,"bounds":{"left":0.013297873,"top":0.33519554,"width":0.06200133,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Userpilot | Events","depth":4,"bounds":{"left":0.0,"top":0.3567438,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Userpilot | Events","depth":5,"bounds":{"left":0.013297873,"top":0.367917,"width":0.030418882,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.38946527,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.40063846,"width":0.2052859,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.42218676,"width":0.07962101,"height":0.032721467},"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.43335995,"width":0.039228722,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Application Performance Monitoring & Error Tracking Software | Sentry","depth":4,"bounds":{"left":0.0,"top":0.45490822,"width":0.07962101,"height":0.032721467},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Application Performance Monitoring & Error Tracking Software | Sentry","depth":5,"bounds":{"left":0.013297873,"top":0.4660814,"width":0.123005316,"height":0.010774142},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.46209097,"width":0.007978723,"height":0.01915403},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.48922586,"width":0.07413564,"height":0.025538707},"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},"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},"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},"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},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Close bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Bookmarks","depth":5,"bounds":{"left":0.083277926,"top":0.06943336,"width":0.026761968,"height":0.014764565},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bookmarks","depth":6,"bounds":{"left":0.083277926,"top":0.06943336,"width":0.026761968,"height":0.014764565},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close sidebar","depth":6,"bounds":{"left":0.1783577,"top":0.06424581,"width":0.010638298,"height":0.025538707},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXTextField","text":"Search bookmarks","depth":7,"bounds":{"left":0.082446806,"top":0.09976058,"width":0.107546546,"height":0.025538707},"help_text":"","role_description":"search text field","subrole":"AXSearchField","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Skip to main content","depth":6,"bounds":{"left":0.19514628,"top":0.051077414,"width":0.0003324468,"height":0.0007980846},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to main content","depth":7,"bounds":{"left":0.19514628,"top":0.056264963,"width":0.05119681,"height":0.015163607},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Welcome Page","depth":8,"bounds":{"left":0.41705453,"top":0.06783719,"width":0.048204787,"height":0.025538707},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"PLATFORM","depth":10,"bounds":{"left":0.4680851,"top":0.06464485,"width":0.04105718,"height":0.031923383},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"PLATFORM","depth":12,"bounds":{"left":0.47406915,"top":0.073822826,"width":0.024933511,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"SOLUTIONS","depth":10,"bounds":{"left":0.5119681,"top":0.06464485,"width":0.042719416,"height":0.031923383},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"SOLUTIONS","depth":12,"bounds":{"left":0.51795214,"top":0.073822826,"width":0.026595745,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"RESOURCES","depth":10,"bounds":{"left":0.5575133,"top":0.06464485,"width":0.04454787,"height":0.031923383},"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"RESOURCES","depth":12,"bounds":{"left":0.56349736,"top":0.073822826,"width":0.02825798,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"DOCS","depth":10,"bounds":{"left":0.6047208,"top":0.06464485,"width":0.024933511,"height":0.031923383},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"DOCS","depth":12,"bounds":{"left":0.6107048,"top":0.073822826,"width":0.012965426,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"PRICING","depth":10,"bounds":{"left":0.63248,"top":0.06464485,"width":0.031083776,"height":0.031923383},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"PRICING","depth":12,"bounds":{"left":0.6384641,"top":0.073822826,"width":0.019115692,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"SIGN IN","depth":9,"bounds":{"left":0.66638964,"top":0.06464485,"width":0.029089095,"height":0.031923383},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SIGN IN","depth":11,"bounds":{"left":0.67237365,"top":0.073822826,"width":0.017121011,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GET DEMO","depth":9,"bounds":{"left":0.6981383,"top":0.06464485,"width":0.034408245,"height":0.031923383},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GET DEMO","depth":11,"bounds":{"left":0.7034575,"top":0.073822826,"width":0.023769947,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GET STARTED","depth":9,"bounds":{"left":0.7352061,"top":0.06464485,"width":0.043550532,"height":0.031923383},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GET STARTED","depth":11,"bounds":{"left":0.74119014,"top":0.073822826,"width":0.03158245,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Marketing Mode ONOFF","depth":7,"bounds":{"left":0.96426195,"top":0.13168396,"width":0.03573805,"height":0.05586592},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Marketing Mode","depth":9,"bounds":{"left":0.96692157,"top":0.14205906,"width":0.030585106,"height":0.011572227},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"ONOFF","depth":8,"bounds":{"left":0.97357047,"top":0.16041501,"width":0.017287234,"height":0.0207502},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"ON","depth":9,"bounds":{"left":0.9765625,"top":0.1660016,"width":0.004654255,"height":0.009577015},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"OFF","depth":9,"bounds":{"left":0.9818817,"top":0.1660016,"width":0.0063164895,"height":0.009577015},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Want to connect with the folks building Sentry? Join us on Discord. ➔","depth":8,"bounds":{"left":0.51263297,"top":0.16041501,"width":0.17037898,"height":0.028731046},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Want to connect with the folks building Sentry? Join us on Discord. ➔","depth":9,"bounds":{"left":0.51728725,"top":0.16799681,"width":0.16107048,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Code breaks, fix it faster","depth":8,"bounds":{"left":0.4320146,"top":0.22745411,"width":0.3316157,"height":0.08419792},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Code","depth":9,"bounds":{"left":0.4320146,"top":0.23822825,"width":0.077792555,"height":0.06304868},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"breaks,","depth":10,"bounds":{"left":0.50664896,"top":0.2386273,"width":0.10305851,"height":0.09417398},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"fix it faster","depth":9,"bounds":{"left":0.61037236,"top":0.23822825,"width":0.15325798,"height":0.06304868},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Application monitoring software considered
\"not bad\" by millions of developers.","depth":9,"bounds":{"left":0.4950133,"top":0.34277734,"width":0.20561835,"height":0.058260176},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GET STARTED","depth":8,"bounds":{"left":0.54288566,"top":0.44972068,"width":0.043550532,"height":0.031923383},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GET STARTED","depth":10,"bounds":{"left":0.54886967,"top":0.45889863,"width":0.03158245,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"SEE HOW IN SANDBOX","depth":8,"bounds":{"left":0.59175533,"top":0.44972068,"width":0.06100399,"height":0.031923383},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SEE HOW IN SANDBOX","depth":10,"bounds":{"left":0.59707445,"top":0.45889863,"width":0.05036569,"height":0.013567438},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GitHub","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Disney","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Atlassian","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Linear","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Vercel","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Cloudflare","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Slack","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Metronome","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Autodesk","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Microsoft","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Instacart","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Lyft","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Bolt","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Monday","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Cursor","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Anthropic","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Factory AI","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Baseten","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Runlayer","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Convex","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Developer first. Always.","depth":9,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Developer first.","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Always.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Monitor in five lines","depth":9,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Monitor in five lines","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Drop in the","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"SDK","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SDK","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":". No agents to install. No performance surprises.","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Automatically root-cause any issue","depth":9,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Automatically root-cause any issue","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Catch critical issues before you merge and fix them when they hit prod with","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Seer, our debugging agent and code reviewer","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Seer, our debugging agent and code reviewer","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Break production less","depth":9,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Break production less","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Predict and prevent the errors that matter most before they even make it to production with","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"AI code review","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"AI code review","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Stay in the flow","depth":9,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Stay in the flow","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"From","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"GitHub","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"GitHub","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Slack","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Slack","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jira","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jira","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", and","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Linear","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Linear","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to coding agents with","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sentry's MCP server","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sentry's MCP server","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", Sentry brings full context to every fix from dev to prod.","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Everything’s connected","depth":10,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Everything’s","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"connected","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Yeah, other tools exist. But","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"errors","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"errors","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"logs","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"logs","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"replays","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"replays","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"spans","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"spans","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"profiles","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"profiles","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", and metrics — all connected by the same trace? That’s kind of our thing.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Go from Issue → Context → Fix.","depth":10,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Go from Issue → Context → Fix.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Debug 500's, trace slow requests, replay fetch() failures, and fix the broken code that caused it.","depth":11,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Catch slow queries, N+1s, and request timeouts before the ‘why is this so slow?’ posts fill up your feed.","depth":11,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Map every incident to the release, PR, and owner -- automatically.","depth":11,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Debugging needs context—
with or without AI","depth":10,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Debugging needs context—","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"with or without AI","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Seer","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Seer","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", our AI debugger, uses Sentry context – logs, commits, traces, stack trace - so you can stop guessing and","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"it","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"can fix issues for you.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Analyzes every signal to explain why your code failed, not just where.","depth":11,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Fixes what’s broken while you ship what’s next – generating precise, merge-ready patches.","depth":11,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Stops bad code before it starts bad days. Correlating PRs against real error and performance history to catch regressions before they ship.","depth":11,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Loved by developers worldwide","depth":8,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Loved by developers","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"worldwide","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"We wouldn’t have scaled without Sentry. Most of our incidents are hardware-related—and we debug them all inside Sentry","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Nova DasSarma","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Systems Lead, Anthropic","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sentry’s high-quality tooling helps Disney+ maintain high-quality service to its tens of millions of global subscribers.","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Andrew Hay","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Director at Disney Streaming Services, Disney+","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The signal we get from Sentry is the most reliable indicator of software issues and is used throughout Instacart because it can be easily configured for each service regardless of the language or framework.","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Igor Dobrovitski","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Infrastructure Software Engineer, Instacart","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Get started in minutes","depth":8,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Get started","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in minutes","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Five lines of code. That's it. No complex setup, no performance hits, no waiting around.","depth":9,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"See -- it's really just one command.","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"npx @sentry/wizard@latest","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-i","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"nextjs","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Get started with just one line of code:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"npx @sentry/wizard@latest","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-i","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"angular","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Just run this command to sign up for and install Sentry.","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"brew","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"install","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getsentry/tools/sentry-wizard","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"&&","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sentry-wizard","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-i","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"android","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Signup and install Sentry with just one line of code:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"brew","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"install","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getsentry/tools/sentry-wizard","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"&&","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sentry-wizard","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-i","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"ios","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sign-up and install Sentry with just one line of code:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"npx @sentry/wizard@latest","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-i","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"flutter","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Install Sentry with one line of code:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"npx @sentry/wizard@latest","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-i","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"reactNative","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Add the Sentry dependency to your .NET MAUI application:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"dotnet","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"add","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"package Sentry.Maui","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"-v","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"5.11","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".1","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Grab the","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sentry Python SDK","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sentry Python SDK","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"pip","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"install","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"--upgrade","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sentry-sdk","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Configure your DSN:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"import","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sentry_sdk\n\nsentry_sdk","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"init","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"https://<key>@sentry.io/<project>\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"# Set traces_sample_rate to 1.0 to capture 100%","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"# of transactions for Tracing.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"# We recommend adjusting this value in production.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"enable_tracing","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"True","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"traces_sample_rate","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1.0","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Grab the","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sentry Node SDK","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sentry Node SDK","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"npm","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"install","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"@sentry/node","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Configure your SDK:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"const","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"require","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'@sentry/node'","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"init","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"dsn","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"'https://<key>@sentry.io/<project>'","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Grab the","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sentry React SDK","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sentry React SDK","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"npm","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"install","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"@sentry/react","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"We recommend putting the Sentry initialization code into its own file and including that file as the first import in your application entry point as shown in the example below:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"import","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"useEffect","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"react\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"import","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"*","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"as","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"@sentry/react\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"init","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"dsn","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"https://examplePublicKey@o0.ingest.sentry.io/0\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"integrations","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"]","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// Set `tracePropagationTargets` to control for which URLs trace propagation should be enabled","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"tracePropagationTargets","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"^\\/","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"^https:\\/\\/yourserver\\.io\\/api","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"]","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Include the Sentry initialization file as the first import statement:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// Sentry initialization should be imported first!","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"import","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"./instrument\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"import","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"App","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"./App\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"import","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"createRoot","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"from","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"react-dom/client\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"const","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"container","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"document","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"getElementById","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"“app”","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"const","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"root","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"createRoot","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"container","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"root","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"render","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"<","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"App","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/>","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Install the","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"NuGet","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"package to add the Sentry dependency:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"dotnet","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"add","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"package Sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Initialize the SDK as early as possible, like in the","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Main","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"method in","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Program.cs/Program.fs","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"using","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SentrySdk","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Init","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"o","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=>","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// Tells which project in Sentry to send events to:","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"o","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Dsn","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"https://<key>@sentry.io/<project>\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// When configuring for the first time, to see what the SDK is doing:","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"o","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Debug","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"true","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// Set TracesSampleRate to 1.0 to capture 100% of transactions for Tracing.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// We recommend adjusting this value in production.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"o","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TracesSampleRate","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1.0","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":";","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// App code goes here - Disposing will flush events out","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Grab the","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sentry Go SDK","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sentry Go SDK","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"go","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"get","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"github.com/getsentry/sentry-go\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Configuration should happen as early as possible in your application's lifecycle:","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Click to Copy","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"package","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"main","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"import","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"log\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"time\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"github.com/getsentry/sentry-go\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"func","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"main","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"err","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":=","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Init","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"ClientOptions","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Dsn","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"https://<key>@sentry.io/<project>\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"EnableTracing","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"true","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// Specify a fixed sample rate:","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// We recommend adjusting this value in production","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TracesSampleRate","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1.0","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// Or provide a custom sample rate:","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TracesSampler","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":":","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TracesSampler","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"func","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"(","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"ctx sentry","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"SamplingContext","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"float64","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// As an example, this does not send some","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"// transactions to Sentry based on their name.","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"if","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"ctx","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Span","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":".","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Name","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"==","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"\"GET /health\"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"{","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"return","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0.0","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"return","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1.0","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":")","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":",","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"}","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-9068080868179632531
|
-3193645322098236170
|
visual_change
|
accessibility
|
NULL
|
Platform Sprint 2 Q2 - Platform Team - Scrum Board Platform Sprint 2 Q2 - Platform Team - Scrum Board - Jira
[SRD-6793] Les Mills activity types not pulling in - Jira
[SRD-6793] Les Mills activity types not pulling in - Jira
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20698 handle failed field sync on playbook import activity types by LakyLak · Pull Request #11988 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
JY-20692 change confirmation parameter by LakyLak · Pull Request #11986 · jiminny/app
[JY-20543] AJ Reports > Tracking - Jira
[JY-20543] AJ Reports > Tracking - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
[JY-18909] [Part2] Automated reports with Ask Jiminny - Jira
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
Ask Jiminny Reports by nikolay-yankov · Pull Request #11894 · jiminny/app
New Tab
New Tab
Product Growth Platform | Userpilot
Product Growth Platform | Userpilot
Userpilot | Events
Userpilot | Events
fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app
fix(security): composer dependency updates – 2026-04-15 by github-actions[bot] · Pull Request #11970 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Application Performance Monitoring & Error Tracking Software | Sentry
Application Performance Monitoring & Error Tracking Software | Sentry
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Close bookmarks (⌘B)
Bookmarks
Bookmarks
Close sidebar
Search bookmarks
Skip to main content
Skip to main content
Welcome Page
PLATFORM
PLATFORM
SOLUTIONS
SOLUTIONS
RESOURCES
RESOURCES
DOCS
DOCS
PRICING
PRICING
SIGN IN
SIGN IN
GET DEMO
GET DEMO
GET STARTED
GET STARTED
Marketing Mode ONOFF
Marketing Mode
ONOFF
ON
OFF
Want to connect with the folks building Sentry? Join us on Discord. ➔
Want to connect with the folks building Sentry? Join us on Discord. ➔
Code breaks, fix it faster
Code
breaks,
fix it faster
Application monitoring software considered
"not bad" by millions of developers.
GET STARTED
GET STARTED
SEE HOW IN SANDBOX
SEE HOW IN SANDBOX
GitHub
Disney
Atlassian
Linear
Vercel
Cloudflare
Slack
Metronome
Autodesk
Microsoft
Instacart
Lyft
Bolt
Monday
Cursor
Anthropic
Factory AI
Sentry
Baseten
Runlayer
Convex
Developer first. Always.
Developer first.
Always.
Monitor in five lines
Monitor in five lines
Drop in the
SDK
SDK
. No agents to install. No performance surprises.
Automatically root-cause any issue
Automatically root-cause any issue
Catch critical issues before you merge and fix them when they hit prod with
Seer, our debugging agent and code reviewer
Seer, our debugging agent and code reviewer
.
Break production less
Break production less
Predict and prevent the errors that matter most before they even make it to production with
AI code review
AI code review
.
Stay in the flow
Stay in the flow
From
GitHub
GitHub
,
Slack
Slack
,
Jira
Jira
, and
Linear
Linear
to coding agents with
Sentry's MCP server
Sentry's MCP server
, Sentry brings full context to every fix from dev to prod.
Everything’s connected
Everything’s
connected
Yeah, other tools exist. But
errors
errors
,
logs
logs
,
replays
replays
,
spans
spans
,
profiles
profiles
, and metrics — all connected by the same trace? That’s kind of our thing.
Go from Issue → Context → Fix.
Go from Issue → Context → Fix.
Debug 500's, trace slow requests, replay fetch() failures, and fix the broken code that caused it.
Catch slow queries, N+1s, and request timeouts before the ‘why is this so slow?’ posts fill up your feed.
Map every incident to the release, PR, and owner -- automatically.
Debugging needs context—
with or without AI
Debugging needs context—
with or without AI
Seer
Seer
, our AI debugger, uses Sentry context – logs, commits, traces, stack trace - so you can stop guessing and
it
can fix issues for you.
Analyzes every signal to explain why your code failed, not just where.
Fixes what’s broken while you ship what’s next – generating precise, merge-ready patches.
Stops bad code before it starts bad days. Correlating PRs against real error and performance history to catch regressions before they ship.
Loved by developers worldwide
Loved by developers
worldwide
We wouldn’t have scaled without Sentry. Most of our incidents are hardware-related—and we debug them all inside Sentry
Nova DasSarma
Systems Lead, Anthropic
Sentry’s high-quality tooling helps Disney+ maintain high-quality service to its tens of millions of global subscribers.
Andrew Hay
Director at Disney Streaming Services, Disney+
The signal we get from Sentry is the most reliable indicator of software issues and is used throughout Instacart because it can be easily configured for each service regardless of the language or framework.
Igor Dobrovitski
Infrastructure Software Engineer, Instacart
Get started in minutes
Get started
in minutes
Five lines of code. That's it. No complex setup, no performance hits, no waiting around.
See -- it's really just one command.
Click to Copy
npx @sentry/wizard@latest
-i
nextjs
Get started with just one line of code:
Click to Copy
npx @sentry/wizard@latest
-i
angular
Just run this command to sign up for and install Sentry.
Click to Copy
brew
install
getsentry/tools/sentry-wizard
&&
sentry-wizard
-i
android
Signup and install Sentry with just one line of code:
Click to Copy
brew
install
getsentry/tools/sentry-wizard
&&
sentry-wizard
-i
ios
Sign-up and install Sentry with just one line of code:
Click to Copy
npx @sentry/wizard@latest
-i
flutter
Install Sentry with one line of code:
Click to Copy
npx @sentry/wizard@latest
-i
reactNative
Add the Sentry dependency to your .NET MAUI application:
Click to Copy
dotnet
add
package Sentry.Maui
-v
5.11
.1
Grab the
Sentry Python SDK
Sentry Python SDK
:
Click to Copy
pip
install
--upgrade
sentry-sdk
Configure your DSN:
Click to Copy
import
sentry_sdk
sentry_sdk
.
init
(
"[URL_WITH_CREDENTIALS]
Configure your SDK:
Click to Copy
const
Sentry
=
require
(
'@sentry/node'
)
;
Sentry
.
init
(
{
dsn
:
'[URL_WITH_CREDENTIALS]
We recommend putting the Sentry initialization code into its own file and including that file as the first import in your application entry point as shown in the example below:
Click to Copy
import
{
useEffect
}
from
"react"
;
import
*
as
Sentry
from
"@sentry/react"
;
Sentry
.
init
(
{
dsn
:
"[URL_WITH_CREDENTIALS]
;
// When configuring for the first time, to see what the SDK is doing:
o
.
Debug
=
true
;
// Set TracesSampleRate to 1.0 to capture 100% of transactions for Tracing.
// We recommend adjusting this value in production.
o
.
TracesSampleRate
=
1.0
;
}
)
)
{
// App code goes here - Disposing will flush events out
}
Grab the
Sentry Go SDK
Sentry Go SDK
:
Click to Copy
go
get
"github.com/getsentry/sentry-go"
Configuration should happen as early as possible in your application's lifecycle:
Click to Copy
package
main
import
(
"log"
"time"
"github.com/getsentry/sentry-go"
)
func
main
(
)
{
err
:=
sentry
.
Init
(
sentry
.
ClientOptions
{
Dsn
:
"https://<key>@sentry.io/<project>"
,
EnableTracing
:
true
,
// Specify a fixed sample rate:
// We recommend adjusting this value in production
TracesSampleRate
:
1.0
,
// Or provide a custom sample rate:
TracesSampler
:
sentry
.
TracesSampler
(
func
(
ctx sentry
.
SamplingContext
)
float64
{
// As an example, this does not send some
// transactions to Sentry based on their name.
if
ctx
.
Span
.
Name
==
"GET /health"
{
return
0.0
}
return
1.0
}
)
,
}...
|
52970
|
|
75825
|
1891
|
10
|
2026-04-24T06:54:42.869910+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-24/1777 /Users/lukas/.screenpipe/data/data/2026-04-24/1777013682869_m1.jpg...
|
Firefox
|
Meet - Daily - Platform — Work
|
True
|
meet.google.com/agt-teir-cwt?authuser=lukas.kovali meet.google.com/agt-teir-cwt?authuser=lukas.kovalik%40jiminny.com...
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Meet - Daily - Platform
Close tab
New Tab
Open Goo Meet - Daily - Platform
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Nikolay Nikolov (Presenting, annotating)
Nikolay Nikolov (Presenting, annotating)
People
7
Take notes with Gemini
Take notes with Gemini
Gemini
Gemini
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Unpin Nikolay Nikolov's presentation from your main screen
You can't unmute someone else's presentation
More options for Nikolay Nikolov
Zoom in
Open in new window
Enter Full Screen
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Stefka Stoyanova to your main screen
You can't unmute someone else
More options for Stefka Stoyanova
Stefka Stoyanova
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Nikolay Nikolov to your main screen
Mute Nikolay Nikolov's microphone
More options for Nikolay Nikolov
Nikolay Nikolov
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Nikolay Yankov to your main screen
You can't unmute someone else
More options for Nikolay Yankov
Nikolay Yankov
2 others
2 others
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
You’re continuously framed
Backgrounds and effects
More options for Lukas Kovalik
Lukas Kovalik
Others might see more of your background. Click to view your full video.
9:54
AM
Daily - Platform
Daily - Platform
Audio settings
Turn off microphone
Video settings
Turn off camera
Nikolay Nikolov is presenting
Send a reaction
Turn on captions
Raise hand (ctrl + ⌘ + h)
More options
Leave call
Meeting details
Chat with everyone
Meeting tools
Your microphone is on....
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Meet - Daily - Platform","depth":4,"bounds":{"left":0.0,"top":0.072222225,"width":0.033680554,"height":0.045555554},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.0013888889,"top":0.072222225,"width":0.010416667,"height":0.016666668},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.005902778,"top":0.12,"width":0.022222223,"height":0.035555556},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.0,"top":0.7977778,"width":0.033680554,"height":0.043333333},"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.0,"top":0.8411111,"width":0.033680554,"height":0.038333334},"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.0,"top":0.8794444,"width":0.033680554,"height":0.03888889},"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.0,"top":0.91833335,"width":0.033680554,"height":0.038333334},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0,"top":0.95666665,"width":0.033680554,"height":0.043333333},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Nikolay Nikolov (Presenting, annotating)","depth":12,"bounds":{"left":0.07534722,"top":0.101111114,"width":0.17916666,"height":0.022222223},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Nikolay Nikolov (Presenting, annotating)","depth":13,"bounds":{"left":0.07534722,"top":0.10222222,"width":0.17916666,"height":0.020555556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"People","depth":15,"bounds":{"left":0.8871528,"top":0.08944444,"width":0.040625,"height":0.04},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":22,"bounds":{"left":0.9149306,"top":0.101111114,"width":0.004513889,"height":0.017222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Take notes with Gemini","depth":14,"bounds":{"left":0.93333334,"top":0.08944444,"width":0.025,"height":0.04},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Take notes with Gemini","depth":17,"bounds":{"left":0.9361111,"top":0.101111114,"width":0.06388891,"height":0.017222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Gemini","depth":22,"bounds":{"left":0.96666664,"top":0.101111114,"width":0.028125,"height":0.017222222},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Gemini","depth":21,"bounds":{"left":0.96458334,"top":0.090555556,"width":0.023611112,"height":0.037777778},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"bounds":{"left":0.5798611,"top":0.61,"width":0.14652778,"height":0.08888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"bounds":{"left":0.7239583,"top":0.6244444,"width":0.08090278,"height":0.018888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"bounds":{"left":0.7017361,"top":0.6205556,"width":0.11076389,"height":0.05666667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Unpin Nikolay Nikolov's presentation from your main screen","depth":13,"bounds":{"left":0.34618056,"top":0.5088889,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"You can't unmute someone else's presentation","depth":13,"bounds":{"left":0.37395832,"top":0.50666666,"width":0.030555556,"height":0.04888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Nikolay Nikolov","depth":13,"bounds":{"left":0.4045139,"top":0.5088889,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Zoom in","depth":13,"bounds":{"left":0.63090277,"top":0.78333336,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open in new window","depth":13,"bounds":{"left":0.6642361,"top":0.78333336,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Enter Full Screen","depth":13,"bounds":{"left":0.69756943,"top":0.78333336,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"bounds":{"left":0.7847222,"top":0.27611113,"width":0.14652778,"height":0.07722222},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"bounds":{"left":0.9288194,"top":0.2911111,"width":0.07118058,"height":0.017777778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"bounds":{"left":0.9065972,"top":0.28666666,"width":0.0934028,"height":0.045},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pin Stefka Stoyanova to your main screen","depth":13,"bounds":{"left":0.7607639,"top":0.25111112,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"You can't unmute someone else","depth":13,"bounds":{"left":0.7885417,"top":0.2488889,"width":0.030555556,"height":0.04888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Stefka Stoyanova","depth":13,"bounds":{"left":0.8190972,"top":0.25111112,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":17,"bounds":{"left":0.753125,"top":0.36277777,"width":0.088194445,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"bounds":{"left":0.91180557,"top":0.27611113,"width":0.08819443,"height":0.07722222},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"bounds":{"left":1.0,"top":0.2911111,"width":-0.05590272,"height":0.017777778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"bounds":{"left":1.0,"top":0.28666666,"width":-0.03368056,"height":0.045},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pin Nikolay Nikolov to your main screen","depth":13,"bounds":{"left":0.8871528,"top":0.25111112,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Mute Nikolay Nikolov's microphone","depth":13,"bounds":{"left":0.9149306,"top":0.2488889,"width":0.030555556,"height":0.04888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Nikolay Nikolov","depth":13,"bounds":{"left":0.9454861,"top":0.25111112,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Nikolay Nikolov","depth":17,"bounds":{"left":0.87951386,"top":0.36277777,"width":0.07847222,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"bounds":{"left":0.78541666,"top":0.5338889,"width":0.14652778,"height":0.07722222},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"bounds":{"left":0.9295139,"top":0.54888886,"width":0.07048613,"height":0.017777778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"bounds":{"left":0.90729165,"top":0.54444444,"width":0.09270835,"height":0.045},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pin Nikolay Yankov to your main screen","depth":13,"bounds":{"left":0.7607639,"top":0.5088889,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"You can't unmute someone else","depth":13,"bounds":{"left":0.7885417,"top":0.50666666,"width":0.030555556,"height":0.04888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Nikolay Yankov","depth":13,"bounds":{"left":0.8190972,"top":0.5088889,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Nikolay Yankov","depth":17,"bounds":{"left":0.753125,"top":0.6205556,"width":0.07673611,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"2 others","depth":11,"bounds":{"left":0.87118053,"top":0.40888888,"width":0.11805555,"height":0.24444444},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 others","depth":13,"bounds":{"left":0.909375,"top":0.55722225,"width":0.041666668,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"You’re continuously framed","depth":13,"bounds":{"left":0.82256943,"top":0.7644445,"width":0.030555556,"height":0.04888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Backgrounds and effects","depth":13,"bounds":{"left":0.853125,"top":0.7644445,"width":0.030555556,"height":0.04888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Lukas Kovalik","depth":13,"bounds":{"left":0.8836806,"top":0.76666665,"width":0.027777778,"height":0.044444446},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Lukas Kovalik","depth":17,"bounds":{"left":0.753125,"top":0.87833333,"width":0.06875,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Others might see more of your background. Click to view your full video.","depth":14,"bounds":{"left":0.96631944,"top":0.875,"width":0.018055556,"height":0.028888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"9:54","depth":12,"bounds":{"left":0.050347224,"top":0.9444444,"width":0.022569444,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"AM","depth":12,"bounds":{"left":0.07638889,"top":0.9444444,"width":0.017361112,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Daily - Platform","depth":12,"bounds":{"left":0.11111111,"top":0.9111111,"width":0.08090278,"height":0.08888888},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Daily - Platform","depth":15,"bounds":{"left":0.11111111,"top":0.9444444,"width":0.08090278,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Audio settings","depth":13,"bounds":{"left":0.32118055,"top":0.9288889,"width":0.06111111,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Turn off microphone","depth":13,"bounds":{"left":0.34895834,"top":0.9288889,"width":0.033333335,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXButton","text":"Video settings","depth":13,"bounds":{"left":0.38784721,"top":0.9288889,"width":0.06111111,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Turn off camera","depth":13,"bounds":{"left":0.415625,"top":0.9288889,"width":0.033333335,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Nikolay Nikolov is presenting","depth":12,"bounds":{"left":0.45451388,"top":0.9288889,"width":0.03888889,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Send a reaction","depth":12,"bounds":{"left":0.49895832,"top":0.9288889,"width":0.03888889,"height":0.053333335},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Turn on captions","depth":13,"bounds":{"left":0.5434028,"top":0.9288889,"width":0.03888889,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Raise hand (ctrl + ⌘ + h)","depth":12,"bounds":{"left":0.58784723,"top":0.9288889,"width":0.03888889,"height":0.053333335},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options","depth":12,"bounds":{"left":0.6322917,"top":0.9288889,"width":0.025,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Leave call","depth":12,"bounds":{"left":0.6628472,"top":0.9288889,"width":0.05,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Meeting details","depth":12,"bounds":{"left":0.89166665,"top":0.9288889,"width":0.033333335,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Chat with everyone","depth":12,"bounds":{"left":0.925,"top":0.9288889,"width":0.033333335,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Meeting tools","depth":12,"bounds":{"left":0.9583333,"top":0.9288889,"width":0.033333335,"height":0.053333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Your microphone is on.","depth":8,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-9067423135811206404
|
-6570499803551368400
|
click
|
accessibility
|
NULL
|
Meet - Daily - Platform
Close tab
New Tab
Open Goo Meet - Daily - Platform
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Nikolay Nikolov (Presenting, annotating)
Nikolay Nikolov (Presenting, annotating)
People
7
Take notes with Gemini
Take notes with Gemini
Gemini
Gemini
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Unpin Nikolay Nikolov's presentation from your main screen
You can't unmute someone else's presentation
More options for Nikolay Nikolov
Zoom in
Open in new window
Enter Full Screen
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Stefka Stoyanova to your main screen
You can't unmute someone else
More options for Stefka Stoyanova
Stefka Stoyanova
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Nikolay Nikolov to your main screen
Mute Nikolay Nikolov's microphone
More options for Nikolay Nikolov
Nikolay Nikolov
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Nikolay Yankov to your main screen
You can't unmute someone else
More options for Nikolay Yankov
Nikolay Yankov
2 others
2 others
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
You’re continuously framed
Backgrounds and effects
More options for Lukas Kovalik
Lukas Kovalik
Others might see more of your background. Click to view your full video.
9:54
AM
Daily - Platform
Daily - Platform
Audio settings
Turn off microphone
Video settings
Turn off camera
Nikolay Nikolov is presenting
Send a reaction
Turn on captions
Raise hand (ctrl + ⌘ + h)
More options
Leave call
Meeting details
Chat with everyone
Meeting tools
Your microphone is on....
|
NULL
|
|
75826
|
1892
|
7
|
2026-04-24T06:54:42.869907+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-24/1777 /Users/lukas/.screenpipe/data/data/2026-04-24/1777013682869_m2.jpg...
|
Firefox
|
Meet - Daily - Platform — Work
|
True
|
meet.google.com/agt-teir-cwt?authuser=lukas.kovali meet.google.com/agt-teir-cwt?authuser=lukas.kovalik%40jiminny.com...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Meet - Daily - Platform
Close tab
New Tab
Open Goo Meet - Daily - Platform
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Nikolay Nikolov (Presenting, annotating)
Nikolay Nikolov (Presenting, annotating)
People
7
Take notes with Gemini
Take notes with Gemini
Gemini
Gemini
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Unpin Nikolay Nikolov's presentation from your main screen
You can't unmute someone else's presentation
More options for Nikolay Nikolov
Zoom in
Open in new window
Enter Full Screen
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Stefka Stoyanova to your main screen
You can't unmute someone else
More options for Stefka Stoyanova
Stefka Stoyanova
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Nikolay Nikolov to your main screen
Mute Nikolay Nikolov's microphone
More options for Nikolay Nikolov
Nikolay Nikolov
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Nikolay Yankov to your main screen
You can't unmute someone else
More options for Nikolay Yankov
Nikolay Yankov
2 others
2 others
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
You’re continuously framed
Backgrounds and effects
More options for Lukas Kovalik
Lukas Kovalik
Others might see more of your background. Click to view your full video.
9:54
AM
Daily - Platform
Daily - Platform
Audio settings
Turn off microphone
Video settings
Turn off camera
Nikolay Nikolov is presenting
Send a reaction
Turn on captions
Raise hand (ctrl + ⌘ + h)
More options
Leave call
Meeting details
Chat with everyone
Meeting tools
Your microphone is on....
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Meet - Daily - Platform","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.016123671,"height":-0.051875472},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.27094415,"top":1.0,"width":0.004986702,"height":-0.051875472},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.27310506,"top":1.0,"width":0.010638298,"height":-0.086193085},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"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,"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,"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,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Nikolay Nikolov (Presenting, annotating)","depth":12,"bounds":{"left":0.30634972,"top":1.0,"width":0.08577128,"height":-0.072625756},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Nikolay Nikolov (Presenting, annotating)","depth":13,"bounds":{"left":0.30634972,"top":1.0,"width":0.08577128,"height":-0.07342374},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"People","depth":15,"bounds":{"left":0.69498,"top":1.0,"width":0.019448139,"height":-0.06424582},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":22,"bounds":{"left":0.70827794,"top":1.0,"width":0.0021609042,"height":-0.072625756},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Take notes with Gemini","depth":14,"bounds":{"left":0.71708775,"top":1.0,"width":0.011968086,"height":-0.06424582},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Take notes with Gemini","depth":17,"bounds":{"left":0.7184175,"top":1.0,"width":0.043550532,"height":-0.072625756},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Gemini","depth":22,"bounds":{"left":0.7330452,"top":1.0,"width":0.013464096,"height":-0.072625756},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Gemini","depth":21,"bounds":{"left":0.73204786,"top":1.0,"width":0.011303191,"height":-0.065043926},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Unpin Nikolay Nikolov's presentation from your main screen","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"You can't unmute someone else's presentation","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Nikolay Nikolov","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Zoom in","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Open in new window","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Enter Full Screen","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pin Stefka Stoyanova to your main screen","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"You can't unmute someone else","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Stefka Stoyanova","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pin Nikolay Nikolov to your main screen","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Mute Nikolay Nikolov's microphone","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Nikolay Nikolov","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Nikolay Nikolov","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pin Nikolay Yankov to your main screen","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"You can't unmute someone else","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Nikolay Yankov","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Nikolay Yankov","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"2 others","depth":11,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"2 others","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pop out this video More screens are more fun. Play this video while you do other things.","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pop out this video","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"More screens are more fun. Play this video while you do other things.","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"You’re continuously framed","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Backgrounds and effects","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options for Lukas Kovalik","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Lukas Kovalik","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Others might see more of your background. Click to view your full video.","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"9:54","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"AM","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Daily - Platform","depth":12,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Daily - Platform","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Audio settings","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Turn off microphone","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXButton","text":"Video settings","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Turn off camera","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Nikolay Nikolov is presenting","depth":12,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Send a reaction","depth":12,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Turn on captions","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Raise hand (ctrl + ⌘ + h)","depth":12,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"More options","depth":12,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Leave call","depth":12,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Meeting details","depth":12,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Chat with everyone","depth":12,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Meeting tools","depth":12,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Your microphone is on.","depth":8,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-9067423135811206404
|
-6570499803551368400
|
click
|
accessibility
|
NULL
|
Meet - Daily - Platform
Close tab
New Tab
Open Goo Meet - Daily - Platform
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Nikolay Nikolov (Presenting, annotating)
Nikolay Nikolov (Presenting, annotating)
People
7
Take notes with Gemini
Take notes with Gemini
Gemini
Gemini
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Unpin Nikolay Nikolov's presentation from your main screen
You can't unmute someone else's presentation
More options for Nikolay Nikolov
Zoom in
Open in new window
Enter Full Screen
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Stefka Stoyanova to your main screen
You can't unmute someone else
More options for Stefka Stoyanova
Stefka Stoyanova
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Nikolay Nikolov to your main screen
Mute Nikolay Nikolov's microphone
More options for Nikolay Nikolov
Nikolay Nikolov
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
Pin Nikolay Yankov to your main screen
You can't unmute someone else
More options for Nikolay Yankov
Nikolay Yankov
2 others
2 others
Pop out this video More screens are more fun. Play this video while you do other things.
Pop out this video
More screens are more fun. Play this video while you do other things.
You’re continuously framed
Backgrounds and effects
More options for Lukas Kovalik
Lukas Kovalik
Others might see more of your background. Click to view your full video.
9:54
AM
Daily - Platform
Daily - Platform
Audio settings
Turn off microphone
Video settings
Turn off camera
Nikolay Nikolov is presenting
Send a reaction
Turn on captions
Raise hand (ctrl + ⌘ + h)
More options
Leave call
Meeting details
Chat with everyone
Meeting tools
Your microphone is on....
|
75824
|
|
15579
|
351
|
5
|
2026-04-14T14:53:34.804048+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-14/1776 /Users/lukas/.screenpipe/data/data/2026-04-14/1776178414804_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ClaudeFileEditViewWindowHelp•40 MoRetro - Platform ClaudeFileEditViewWindowHelp•40 MoRetro - Platform • 22m leftATue 14 Apr 17:53:34+ New chatQ SearchcustomizeChatsProjects88 ArtifactsStarredBulgarian citizenship application..Dawarich location tracking projecthecentsClaude API 500 internal server e.Screenpipe query capabilities a...eGPU compatibility with Mac mi...Understanding OpenRouter API...Screenpipe 14-day retention exp...Reddit homepage feed overviewVocke concnernorv siele ln C.nDIA browser RAM configurationScreenpipe company backgroundDSK Bank Bulgaria secure invest.Building a comprehensive perso...Importing Google Timeline data t...Chromecast remote volume butt...Screenpipe free version guideSelf-hosted LLM alternatives to ...Screenpipe database query setupRunning screenpipe for freeMac Studio network switch setupClaude extension in Vivaldi brow...General information requestKed dors onneck causesChange approvalClaude AP 500 internal server errorCnaucoworkCodeSUM(LENGTH(o. text))/1024/1024 as total_mbFROM ocr_text oJOIN frames f ON o.frame_id = f.idGROUP BY f.app_nameORDER BY total_mb DESCLIMIT 15;"But already the frames data tells a lot:6198 frames with empty app name — that's the biggest group and almost certainly theScreenpipe Dashboard itself (Firefox tab with no app name resolved, or possibly thedashboard running on your NAS). This is the noise we discussed ignoring.Boosteroid: 3593 frames - second biggest, more than Firefox (2008) and iTerm2 (1197)combined. And gaming frames are the worst for OCR — lots of changing pixels, long textextractions from game UI, zero useful content. This is almost certainly your biggest DBcontributor proportionally.The math so far:• 5 days of data = 1.1 GB DB•boosteroia aloneis ~24 % or allTrames• If you add the empty-name frames (likely dashboard) that's another ~41%So roughly 65% of your DB is probably noise - Boosteroid + dashboard frames you don'twant anyway.Run the fixed OCR query to confirm, then we'll have a clear picture of eslactly what's worthkeeping vs ignoring.Updated to 1.2278.0RelaunchLukas KovalikPro nianLIMIT 15;"|6206|2989.91637125363|17Boosteroid|3593|1169.43362092959|4Slack|387|3164.1834625323|1PhpStorm|299|3867.36454849498|1Firefox|366|3405.68852459016|1iTerm2|10|2601.1|0UserNotificationCenter|1|664.0|0Tailscale|2|3256.5|0Raycast|2|1539.5|0QuickTime Player|15|3574.33333333333|0Preview|1|2829.010Finder|38|2407.44736842105|0Dia|63|2062.65079365079|0CoreServicesUlAgent|1|1962.0|0Control Centre|12|4385.83333333333|0lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe$Sonnet 46Claudtro mrand can make mistakes. rlease double-cneck responses...
|
NULL
|
-9067190502699576549
|
NULL
|
visual_change
|
ocr
|
NULL
|
ClaudeFileEditViewWindowHelp•40 MoRetro - Platform ClaudeFileEditViewWindowHelp•40 MoRetro - Platform • 22m leftATue 14 Apr 17:53:34+ New chatQ SearchcustomizeChatsProjects88 ArtifactsStarredBulgarian citizenship application..Dawarich location tracking projecthecentsClaude API 500 internal server e.Screenpipe query capabilities a...eGPU compatibility with Mac mi...Understanding OpenRouter API...Screenpipe 14-day retention exp...Reddit homepage feed overviewVocke concnernorv siele ln C.nDIA browser RAM configurationScreenpipe company backgroundDSK Bank Bulgaria secure invest.Building a comprehensive perso...Importing Google Timeline data t...Chromecast remote volume butt...Screenpipe free version guideSelf-hosted LLM alternatives to ...Screenpipe database query setupRunning screenpipe for freeMac Studio network switch setupClaude extension in Vivaldi brow...General information requestKed dors onneck causesChange approvalClaude AP 500 internal server errorCnaucoworkCodeSUM(LENGTH(o. text))/1024/1024 as total_mbFROM ocr_text oJOIN frames f ON o.frame_id = f.idGROUP BY f.app_nameORDER BY total_mb DESCLIMIT 15;"But already the frames data tells a lot:6198 frames with empty app name — that's the biggest group and almost certainly theScreenpipe Dashboard itself (Firefox tab with no app name resolved, or possibly thedashboard running on your NAS). This is the noise we discussed ignoring.Boosteroid: 3593 frames - second biggest, more than Firefox (2008) and iTerm2 (1197)combined. And gaming frames are the worst for OCR — lots of changing pixels, long textextractions from game UI, zero useful content. This is almost certainly your biggest DBcontributor proportionally.The math so far:• 5 days of data = 1.1 GB DB•boosteroia aloneis ~24 % or allTrames• If you add the empty-name frames (likely dashboard) that's another ~41%So roughly 65% of your DB is probably noise - Boosteroid + dashboard frames you don'twant anyway.Run the fixed OCR query to confirm, then we'll have a clear picture of eslactly what's worthkeeping vs ignoring.Updated to 1.2278.0RelaunchLukas KovalikPro nianLIMIT 15;"|6206|2989.91637125363|17Boosteroid|3593|1169.43362092959|4Slack|387|3164.1834625323|1PhpStorm|299|3867.36454849498|1Firefox|366|3405.68852459016|1iTerm2|10|2601.1|0UserNotificationCenter|1|664.0|0Tailscale|2|3256.5|0Raycast|2|1539.5|0QuickTime Player|15|3574.33333333333|0Preview|1|2829.010Finder|38|2407.44736842105|0Dia|63|2062.65079365079|0CoreServicesUlAgent|1|1962.0|0Control Centre|12|4385.83333333333|0lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe$Sonnet 46Claudtro mrand can make mistakes. rlease double-cneck responses...
|
15577
|
|
34392
|
692
|
11
|
2026-04-16T08:33:42.276538+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-16/1776 /Users/lukas/.screenpipe/data/data/2026-04-16/1776328422276_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelpSupport Daily - in 3 h 27 m100% <478 Thu 16 Apr 11:33:41-zshDOCKER• 8812026-04-16110:58:56.12124022026-04-16T10:59:00.498686Z2026-04-16T10:59:09.377495Z2026-04-16110:59:36.54551222026-04-16T11:04:15.237763Z\nFROM\nframes \nWHERE\n-5.844181583s2026-04-16T11:04:15.238562Z2026-04-16T11:04:24.012117Z2026-04-16T11:04:40.465386Z2026-04-16111:09:42.6251402\nFROM\nframes\nWHERE\nd=2.14471325s2026-04-16111:09:42.63046722026-04-16T11:09:51.298246Z2026-04-16T11:10:06.899071Z2026-04-16T11:15:13.835798Z\nFROM\nframes\nWHERE\nd-6.920954875s2026-04-16T11:15:13.840780Z2026-04-16711:15:21.35774222026-04-16T11:15:39.238002Z2026-04-16T11:15:39.455620ZnFROM \nframes \nWHERE\n56582333s2026-04-16T11:20:43.244760Z\nFROM\nframes \nWHERE\nd=3.953801125s2026-04-16T11:20:43.246740Z2026-04-16T11:20:48.600603Z2026-04-16711:21:08.118947Z2026-04-16T11:23:17.751550Z2026-04-16T11:26:11.761391ZInFROM\nframes \nWHERE\nd-3.593101083s2026-04-16111:26:11.76267922026-04-16T11:26:15.500463Z2026-04-16T11:26:26.014153Z2026-04-16T11:31:28.731069Z\nFROM\nframes\nWHERE\nd=2.600208041s2026-04-16T11:31:28.732839Z2026-04-16T11:31:33.926759Z2026-04-16111:31:48.4032732DEV (docker)882APP (-zsh)83ec2-user@ip-10-30-...-zsh• 85-zsh86-zsh₴7* Unable to acce...O 88INFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: found 117eligibleframesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 44 frames, 11.0MB → 3.4MB (3.2x), 44 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 71 frames, 11.6MB → 3.7MB (3.2x),71 JPEGSdeletedINFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 2 (hash=-5207847904424027181, trigger=visual_change)WARNsqlx:: query:summary="SELECT id, snapshot_path, device_name, .'db.statement="\n\nSELECT\nid, \nsnapshot_path IS NOTNULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\nsnapshot_path,\ndevice_name, \ntimestamp5000\n*rows_affected=0 rows_returned=99 elapsedINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: found 99eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 40 frames,12.OMB → 3.4MB (3.6x), 40 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 57 frames,10.2MB → 3.4MB (3.0x), 57 JPEGs deleted: 3Ne C.80,WARNsqlx::query:summary="SELECT id, snapshot_path, device_name,_" db. statement="\n\nSELECT\nid, Insnapshot_path IS NOTNULL\nsnapshot_path, \ndevice_name, intimestampAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n 5000\n"rows_affected=0 rows_returned=132 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 132 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 51 frames, 18.6MB → 6.6MB (2.8x), 51 JPEGSdeletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 79 frames,11.2MB → 3.8MB (2.9x),79 JPEGSdeletedWARNIsqlx::query:summary="SELECT id, snapshot_path, device_name, _" db.statement="\n\nSELECT\n id,\nsnapshot_path, \ndevice_name, \ntimestampsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n5000\n'rows_affected=0 rows_returned=100 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 100 eligible framesINFOINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 33 frames, 12.5MB → 1.9MB (6.7x), 33 JPEGs deletedscreenpipe_engine::snapshot_compaction: snapshot compaction: 65 frames, 13.8MB → 5.2MB (2.6x), 65 JPEGs deletedWARNsqlx::query:summary="SELECT DISTINCT app_name, window_name, ." db.statement="\n\nSELECT\n DISTINCT app_name, \nwindow_name, \nbrowser_url\timestamp >datetime('now'•-30seconds')\nAND app_name IS NOT NULL\nAND window_name IS NOT NULL\n"rows_affected=0 rows_returned=147 elapsed-2.0WARNsqlx::query:summary="SELECT id,snapshot_path, device_name,snapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ndb.statement="\n\nSELECT\nid,\nsnapshot_path, \ndevice_name, \ntimestamptimestamp ASC\nLIMIT\n5000\n'rows_affected=0 rows_returned=118 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 118 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 40 frames,15.6MB → 1.2MB (12.9x), 40 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 76 frames, 12.6MB → 5.4MB (2.3x),76 JPEGs deletedINFOWARNscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=-191790730269621217, trigger=click)sqlx::query:summary="SELECT id, snapshot_path, device_name, ..."db.statement="\n\nSELECT\nid, \nsnapshot_path, \ndevice_name, \ntimestampsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BYn device_name, \ntimestamp ASC\nLIMIT\n5000\n" rows_affected-0 rows_returned-114 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 114 eligible framesINFO screenpipe_engine::snapshot_compaction: snapshot compaction: 32 frames,INFO12.1MB → 2.4MB (5.0x), 32 JPEGs deletedscreenpipe_engine::snapshot_compaction: snapshot compaction: 80 frames,13.4MB → 6.4MB (2.1x), 80 JPEGs deletedWARNsqlx::query:summary="SELECT id, snapshot_path, device_name, "db.statement="\n\nSELECT\nid, \nsnapshot_path, \ndevice_name, \n timestampsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n5000\n"rows_affected=0 rows_returned=128 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 128 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 40 frames, 11.9MB → 2.6MB (4.6x), 40 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 86 frames, 19.0MB → 6.3MB (3.0x), 86 JPEGs deleted...
|
NULL
|
-9066621028679435564
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelpSupport Daily - in 3 h 27 m100% <478 Thu 16 Apr 11:33:41-zshDOCKER• 8812026-04-16110:58:56.12124022026-04-16T10:59:00.498686Z2026-04-16T10:59:09.377495Z2026-04-16110:59:36.54551222026-04-16T11:04:15.237763Z\nFROM\nframes \nWHERE\n-5.844181583s2026-04-16T11:04:15.238562Z2026-04-16T11:04:24.012117Z2026-04-16T11:04:40.465386Z2026-04-16111:09:42.6251402\nFROM\nframes\nWHERE\nd=2.14471325s2026-04-16111:09:42.63046722026-04-16T11:09:51.298246Z2026-04-16T11:10:06.899071Z2026-04-16T11:15:13.835798Z\nFROM\nframes\nWHERE\nd-6.920954875s2026-04-16T11:15:13.840780Z2026-04-16711:15:21.35774222026-04-16T11:15:39.238002Z2026-04-16T11:15:39.455620ZnFROM \nframes \nWHERE\n56582333s2026-04-16T11:20:43.244760Z\nFROM\nframes \nWHERE\nd=3.953801125s2026-04-16T11:20:43.246740Z2026-04-16T11:20:48.600603Z2026-04-16711:21:08.118947Z2026-04-16T11:23:17.751550Z2026-04-16T11:26:11.761391ZInFROM\nframes \nWHERE\nd-3.593101083s2026-04-16111:26:11.76267922026-04-16T11:26:15.500463Z2026-04-16T11:26:26.014153Z2026-04-16T11:31:28.731069Z\nFROM\nframes\nWHERE\nd=2.600208041s2026-04-16T11:31:28.732839Z2026-04-16T11:31:33.926759Z2026-04-16111:31:48.4032732DEV (docker)882APP (-zsh)83ec2-user@ip-10-30-...-zsh• 85-zsh86-zsh₴7* Unable to acce...O 88INFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: found 117eligibleframesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 44 frames, 11.0MB → 3.4MB (3.2x), 44 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 71 frames, 11.6MB → 3.7MB (3.2x),71 JPEGSdeletedINFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 2 (hash=-5207847904424027181, trigger=visual_change)WARNsqlx:: query:summary="SELECT id, snapshot_path, device_name, .'db.statement="\n\nSELECT\nid, \nsnapshot_path IS NOTNULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\nsnapshot_path,\ndevice_name, \ntimestamp5000\n*rows_affected=0 rows_returned=99 elapsedINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: found 99eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 40 frames,12.OMB → 3.4MB (3.6x), 40 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 57 frames,10.2MB → 3.4MB (3.0x), 57 JPEGs deleted: 3Ne C.80,WARNsqlx::query:summary="SELECT id, snapshot_path, device_name,_" db. statement="\n\nSELECT\nid, Insnapshot_path IS NOTNULL\nsnapshot_path, \ndevice_name, intimestampAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n 5000\n"rows_affected=0 rows_returned=132 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 132 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshotcompaction: 51 frames, 18.6MB → 6.6MB (2.8x), 51 JPEGSdeletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 79 frames,11.2MB → 3.8MB (2.9x),79 JPEGSdeletedWARNIsqlx::query:summary="SELECT id, snapshot_path, device_name, _" db.statement="\n\nSELECT\n id,\nsnapshot_path, \ndevice_name, \ntimestampsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n5000\n'rows_affected=0 rows_returned=100 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 100 eligible framesINFOINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 33 frames, 12.5MB → 1.9MB (6.7x), 33 JPEGs deletedscreenpipe_engine::snapshot_compaction: snapshot compaction: 65 frames, 13.8MB → 5.2MB (2.6x), 65 JPEGs deletedWARNsqlx::query:summary="SELECT DISTINCT app_name, window_name, ." db.statement="\n\nSELECT\n DISTINCT app_name, \nwindow_name, \nbrowser_url\timestamp >datetime('now'•-30seconds')\nAND app_name IS NOT NULL\nAND window_name IS NOT NULL\n"rows_affected=0 rows_returned=147 elapsed-2.0WARNsqlx::query:summary="SELECT id,snapshot_path, device_name,snapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ndb.statement="\n\nSELECT\nid,\nsnapshot_path, \ndevice_name, \ntimestamptimestamp ASC\nLIMIT\n5000\n'rows_affected=0 rows_returned=118 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 118 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 40 frames,15.6MB → 1.2MB (12.9x), 40 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 76 frames, 12.6MB → 5.4MB (2.3x),76 JPEGs deletedINFOWARNscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=-191790730269621217, trigger=click)sqlx::query:summary="SELECT id, snapshot_path, device_name, ..."db.statement="\n\nSELECT\nid, \nsnapshot_path, \ndevice_name, \ntimestampsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BYn device_name, \ntimestamp ASC\nLIMIT\n5000\n" rows_affected-0 rows_returned-114 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 114 eligible framesINFO screenpipe_engine::snapshot_compaction: snapshot compaction: 32 frames,INFO12.1MB → 2.4MB (5.0x), 32 JPEGs deletedscreenpipe_engine::snapshot_compaction: snapshot compaction: 80 frames,13.4MB → 6.4MB (2.1x), 80 JPEGs deletedWARNsqlx::query:summary="SELECT id, snapshot_path, device_name, "db.statement="\n\nSELECT\nid, \nsnapshot_path, \ndevice_name, \n timestampsnapshot_path IS NOT NULL\nAND timestamp < ?1\nORDER BY\ndevice_name, \ntimestamp ASC\nLIMIT\n5000\n"rows_affected=0 rows_returned=128 elapseINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 128 eligible framesINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 40 frames, 11.9MB → 2.6MB (4.6x), 40 JPEGs deletedINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 86 frames, 19.0MB → 6.3MB (3.0x), 86 JPEGs deleted...
|
NULL
|
|
36401
|
739
|
14
|
2026-04-16T10:33:15.042404+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-16/1776 /Users/lukas/.screenpipe/data/data/2026-04-16/1776335595042_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditHistoryBookmarksProfilesToolsWindow FirefoxFileEditHistoryBookmarksProfilesToolsWindowHelp= app.dev.jiminny.com/connect/zohocrmlablSupport Daily - in 1h 27 m100% C2Thu 16 Apr 13:33:147 Jiminny x Shiji - Reconnecting theZ For you - Confluence• Lukas Kovalik - Time Offu Product Growth Plattorm Userpilou Userpilotfix(security): composer dependera JiminnyNew Tab8 Jiminny© GoogleIntegrationAccessor Memorane1 Jminny • Memorane( Fix an autocomplete mistake that s) Symfony\Component\Debug\ExcepE App "Zoho CRM" • Kavita • Membra+ New Tab2 Zoho CRM2L• Inspector• Console DebuggerNL Network {} Style Editor ( PerformanceO: MemoryStoragei Accessibilityû | Filter OutputErrors Warnings Info Logs Debug020CSsXHRRequests•Nayesof the "e conputhe has in hexir/sPity ntto/bore soezt ther ChutSn i TeMsuresoug quC NE Ps://fonts, 8a leapis, con/cs322family-=TB8MPlexsSertf&edise sennecetA MouseEvent.mozInputSource is deprecated. Use PointerEvent-pointerType instead.A Storage access automatically aranted for origin "httos://ui.integration.app" on "https://app.dev.iiminnv.com"ction resolved: {"id"="69e0b983da98fa74f98aebfb", "name" ="Connection to 66fe6c913202f3a165e3c14d for Dev Zohoconnect-brhELSKM.5:4:2/0012735-5702 p tat 432826-86-16720-3144 eseA Stackein the worker: networkguquts taresourath stettoo 1s/client/shared/source-map-loader/uti 15/ network-request- j5:43:9Resource URL:Resrce Map URL: Coil/t-BrFtLskm. J5.aC ILum Morts/assets/connect-BFFtTsKn.1SNayesof The aomp hahesh is hexpr/sPatev mxtriburintent of the suoresource at "nttos.tonts.googleapiscom/css/tamtlv=lb"+P lex+serttodiso conneciA MouseEvent-mozInputSource is deprecated. Use PointerEvent.pointerType instead.Top +Linking your Zoho CRM accountPlease select one of authentication options:Connecting@ A popup window should open, please proceed thereconnectedjunt has become disconnectedct to continueth Zoho CRM...
|
NULL
|
-9066518604139956789
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEditHistoryBookmarksProfilesToolsWindow FirefoxFileEditHistoryBookmarksProfilesToolsWindowHelp= app.dev.jiminny.com/connect/zohocrmlablSupport Daily - in 1h 27 m100% C2Thu 16 Apr 13:33:147 Jiminny x Shiji - Reconnecting theZ For you - Confluence• Lukas Kovalik - Time Offu Product Growth Plattorm Userpilou Userpilotfix(security): composer dependera JiminnyNew Tab8 Jiminny© GoogleIntegrationAccessor Memorane1 Jminny • Memorane( Fix an autocomplete mistake that s) Symfony\Component\Debug\ExcepE App "Zoho CRM" • Kavita • Membra+ New Tab2 Zoho CRM2L• Inspector• Console DebuggerNL Network {} Style Editor ( PerformanceO: MemoryStoragei Accessibilityû | Filter OutputErrors Warnings Info Logs Debug020CSsXHRRequests•Nayesof the "e conputhe has in hexir/sPity ntto/bore soezt ther ChutSn i TeMsuresoug quC NE Ps://fonts, 8a leapis, con/cs322family-=TB8MPlexsSertf&edise sennecetA MouseEvent.mozInputSource is deprecated. Use PointerEvent-pointerType instead.A Storage access automatically aranted for origin "httos://ui.integration.app" on "https://app.dev.iiminnv.com"ction resolved: {"id"="69e0b983da98fa74f98aebfb", "name" ="Connection to 66fe6c913202f3a165e3c14d for Dev Zohoconnect-brhELSKM.5:4:2/0012735-5702 p tat 432826-86-16720-3144 eseA Stackein the worker: networkguquts taresourath stettoo 1s/client/shared/source-map-loader/uti 15/ network-request- j5:43:9Resource URL:Resrce Map URL: Coil/t-BrFtLskm. J5.aC ILum Morts/assets/connect-BFFtTsKn.1SNayesof The aomp hahesh is hexpr/sPatev mxtriburintent of the suoresource at "nttos.tonts.googleapiscom/css/tamtlv=lb"+P lex+serttodiso conneciA MouseEvent-mozInputSource is deprecated. Use PointerEvent.pointerType instead.Top +Linking your Zoho CRM accountPlease select one of authentication options:Connecting@ A popup window should open, please proceed thereconnectedjunt has become disconnectedct to continueth Zoho CRM...
|
NULL
|
|
59501
|
1282
|
3
|
2026-04-20T13:51:36.842626+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-20/1776 /Users/lukas/.screenpipe/data/data/2026-04-20/1776693096842_m1.jpg...
|
Firefox
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFilelEditViewHistoryBookmarksProfiles|Tools FirefoxFilelEditViewHistoryBookmarksProfiles|ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=0100% C28• Mon 20 Apr 16:51:368+881IOGalya Dimitrova (Presenting, annotating)ChromeFileEditViewHistoryBookmarksProfilesTabWindowHelp; ProdG PE -# Discc XG Cres x |® Chat xProdThe! xapp.jiminny.com/ondemand?min_duration=1&only_recorded=1&sequence_number=18 Jiminny ProdJiminny EU ProdJiminny Mercury• Jiminny Staging• Jiminny Saturn Jiminny QAI• Jiminny QA|O 1PasswordSavou scarch14261 activitiesSort by: Most recent|TeamRecorded: Only RecordedB) Save Search @ClearallHostHostActivityContactActivity TypeFoursquare 1Linda Ludewig and Gabriela DurevaAlso search as participant|OnboardingWorkshopParticipantErik Pernstam at Allakando ** Meet with Oliver from JiminnyErik PernstamDiscoveryCustomer ©Maria Chico at Superside #Jiminny x SupersideMaria ChicoKick-Off/OnboardingTranscript OMarin Nedevski at Reward Ga...4VIMarin NedevskiExpansion /UpsellJheelama Ghosh and Lauren HudsonSaid byJen Cathcart at PCS *)Jen CathcartJen Cathcart and Zornitsa Dzhong..CS technical Support|ArtyonePeriodSteve Lazarus at Lazarus Cons...Steve LazarusAll timeSteve Lazarus and Greg MoserTopicsSearch topic:Melanie Yu Yu at Unibuddy WJiminny < > Umibuddy (weekly accel.Melanic Yu YuTechnical set-upActivity typemeet.google.com is sharing your screen.Stop sharingHideDiane152-8 • Mon 20 Apr 16:51CowLK MO• WorkFinish updateUserpilotSalesforce3 Outlook3ô HubSpotAdd Recording |4* Get NotihedCurrent StageStatsDurationDateLikely to RenewUS$34.877"01o35ml20/04/2026. 3:34 PM31m20/04/2026. 3:32 PM25ml20/04/2026.1:53PM33ml20/04/2026, 1:32 PMLikely to RenewUS$56,40033ml20/04/2026. 12:31 PM27ml17/04/2026, 5:57 PM32m17/04/2026, 5:30 PMNikolay lvanovGalya Dimitrova3 othersNikolay YankovLikely to Repe4:51 PM | [Platform] Refinement •Lukas Kovalik1:49:48...
|
NULL
|
-9065685357164112492
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFilelEditViewHistoryBookmarksProfiles|Tools FirefoxFilelEditViewHistoryBookmarksProfiles|ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=0100% C28• Mon 20 Apr 16:51:368+881IOGalya Dimitrova (Presenting, annotating)ChromeFileEditViewHistoryBookmarksProfilesTabWindowHelp; ProdG PE -# Discc XG Cres x |® Chat xProdThe! xapp.jiminny.com/ondemand?min_duration=1&only_recorded=1&sequence_number=18 Jiminny ProdJiminny EU ProdJiminny Mercury• Jiminny Staging• Jiminny Saturn Jiminny QAI• Jiminny QA|O 1PasswordSavou scarch14261 activitiesSort by: Most recent|TeamRecorded: Only RecordedB) Save Search @ClearallHostHostActivityContactActivity TypeFoursquare 1Linda Ludewig and Gabriela DurevaAlso search as participant|OnboardingWorkshopParticipantErik Pernstam at Allakando ** Meet with Oliver from JiminnyErik PernstamDiscoveryCustomer ©Maria Chico at Superside #Jiminny x SupersideMaria ChicoKick-Off/OnboardingTranscript OMarin Nedevski at Reward Ga...4VIMarin NedevskiExpansion /UpsellJheelama Ghosh and Lauren HudsonSaid byJen Cathcart at PCS *)Jen CathcartJen Cathcart and Zornitsa Dzhong..CS technical Support|ArtyonePeriodSteve Lazarus at Lazarus Cons...Steve LazarusAll timeSteve Lazarus and Greg MoserTopicsSearch topic:Melanie Yu Yu at Unibuddy WJiminny < > Umibuddy (weekly accel.Melanic Yu YuTechnical set-upActivity typemeet.google.com is sharing your screen.Stop sharingHideDiane152-8 • Mon 20 Apr 16:51CowLK MO• WorkFinish updateUserpilotSalesforce3 Outlook3ô HubSpotAdd Recording |4* Get NotihedCurrent StageStatsDurationDateLikely to RenewUS$34.877"01o35ml20/04/2026. 3:34 PM31m20/04/2026. 3:32 PM25ml20/04/2026.1:53PM33ml20/04/2026, 1:32 PMLikely to RenewUS$56,40033ml20/04/2026. 12:31 PM27ml17/04/2026, 5:57 PM32m17/04/2026, 5:30 PMNikolay lvanovGalya Dimitrova3 othersNikolay YankovLikely to Repe4:51 PM | [Platform] Refinement •Lukas Kovalik1:49:48...
|
59498
|
|
15716
|
352
|
1
|
2026-04-14T14:58:22.392524+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-14/1776 /Users/lukas/.screenpipe/data/data/2026-04-14/1776178702392_m1.jpg...
|
Boosteroid
|
Boosteroid
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowH iTerm2ShellEditViewSessionScriptsProfilesWindowHelp>0 lbl•Retro - Platform - 17 m leftA-zshDOCKER• 881DEV (-zsh)O 82APP (-zsh)[EMAIL] SettingsI59Activity MonitorI45OrionI43CleanShot XI42lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/ screenpipe $ sqlite3 ~/.screenpipe/db.sqlite "SELECTapp_name,COUNT(*) as rows,AVG(LENGTH(text)) as avg_text_len,SUM(LENGTH(text))/1024/1024 as total_mbFROM ocr_textJOIN frames ON ocr_text.frame_id = frames.idGROUP BY app_nameORDER BY total_mb DESCLIMIT 15;"Error: in prepare, ambiguous column name: app_nameSELECTapp_name,COUNT(*) as rows,AeeeAVG(LENGTH(text)) as avg_text_len,error hereLukas®Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ sqlite3 ~/.screenpipe/db.sqlite "SELECTf.app_name,COUNT(*) as rows,AVG(LENGTH(o.text)) as avg_text_len,SUMCLENGTH(o.text))/1024/1024 as total_mbFROM ocr_text oJOIN frames f ON o. frame_id = f.idGROUP BY f.app_nameORDER BY total_mb DESCLIMIT 15;"16206|2989.91637125363117Boosteroid 3593|1169.4336209295914Slack 38713164.1834625323|1PhpStorm|29913867.36454849498|1FirefoxI36613405.68852459016|1iTerm2|10|2601.110UserNotificationCenter|11664.010Tailscalel213256.510Raycast|2|1539.510QuickTime Player|1513574.3333333333310Preview|112829.010Finder|3812407.4473684210510Dial6312062.6507936507910CoreServicesUIAgent|1|1962.010Control Centrel1214385.8333333333310lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $-zsh-zsh86-zsh100% <47Tue 14 Apr 17:58:22T810 87* Unable to acce...O x8...
|
NULL
|
-9065385930346121847
|
NULL
|
visual_change
|
ocr
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowH iTerm2ShellEditViewSessionScriptsProfilesWindowHelp>0 lbl•Retro - Platform - 17 m leftA-zshDOCKER• 881DEV (-zsh)O 82APP (-zsh)[EMAIL] SettingsI59Activity MonitorI45OrionI43CleanShot XI42lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/ screenpipe $ sqlite3 ~/.screenpipe/db.sqlite "SELECTapp_name,COUNT(*) as rows,AVG(LENGTH(text)) as avg_text_len,SUM(LENGTH(text))/1024/1024 as total_mbFROM ocr_textJOIN frames ON ocr_text.frame_id = frames.idGROUP BY app_nameORDER BY total_mb DESCLIMIT 15;"Error: in prepare, ambiguous column name: app_nameSELECTapp_name,COUNT(*) as rows,AeeeAVG(LENGTH(text)) as avg_text_len,error hereLukas®Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $ sqlite3 ~/.screenpipe/db.sqlite "SELECTf.app_name,COUNT(*) as rows,AVG(LENGTH(o.text)) as avg_text_len,SUMCLENGTH(o.text))/1024/1024 as total_mbFROM ocr_text oJOIN frames f ON o. frame_id = f.idGROUP BY f.app_nameORDER BY total_mb DESCLIMIT 15;"16206|2989.91637125363117Boosteroid 3593|1169.4336209295914Slack 38713164.1834625323|1PhpStorm|29913867.36454849498|1FirefoxI36613405.68852459016|1iTerm2|10|2601.110UserNotificationCenter|11664.010Tailscalel213256.510Raycast|2|1539.510QuickTime Player|1513574.3333333333310Preview|112829.010Finder|3812407.4473684210510Dial6312062.6507936507910CoreServicesUIAgent|1|1962.010Control Centrel1214385.8333333333310lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/.screenpipe $-zsh-zsh86-zsh100% <47Tue 14 Apr 17:58:22T810 87* Unable to acce...O x8...
|
NULL
|
|
44678
|
944
|
8
|
2026-04-17T08:58:06.958412+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776416286958_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileFditViewNavigateCodelaravelRefactonToo PhpStormFileFditViewNavigateCodelaravelRefactonToolsWindowHelpFV faVsco.s v#11894 on JY-18909-automated-reports-ask-iminny K vProject v(C AutomatedReportsService.phpC) SendReportJob.phpC SendReportMailJob.phplorw se cuev.seC ReportController.phpM+ README.mdTokenBuilder.phpC TeamSetupController.phppnp apl.ono• Filesystem.php{o sonar-project.properties© Team.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.php© ActivityLogged.php=test.pyC AutomatedReportsCallbackService.phpC RequestGenerateAskJiminnyReportJob.php‹> Untitled Diagram.xmlC RequestGenerateReportJob.phgC SyncOpportunity.phpT OpportunitySyncTrait.php xC Opportunity.phpus vetur.config.jsM. WEBHOOK FILTERING IMPLE© OpportunityUpdated.phpc) onnortuntvstadendated.nno© EventServiceProvider.php›Ub External LibrariesScratches and ConsolesC OpportunityPendingAiAnalysisAfterStageChanged.phpC RunOpportunityAiAnalysis.phpv M Database ConsolesC ProcessAiAutomationAnalysisResults.php(C) ImportOpportunityBatch.phpT ImportBatchJobTrait.phpV AEUHasattributes.onoC) Service.php© AutomatedReportResult.php© AutomatedReport.phpc consoe -u4 DEAL RISKS [EUT"podcast audio url"Cc W0 results& DI EUT({)BS2 M2M19 ^Services(nJ Outputliii jiminny.business process stages Xv _ Database8 rowsvIX. AutoVdEUo-e business process_id Yoestage id YДQMAФI created at Y« console 1s 475 mso0y4w crm_contigurations 1s 504 ms10049 2020-04-02 12:39.1016351 2025-04-02 12:59:16d jiminny@localhostASF602416352 2025-04-02 12:59:16c moocall602416555 2025-04-02 12:57:10V A PROD602416354 2025-04-02 12•59:16< console ls o0o ms602416355 2025-04-02 12:59:16V L STAGING« console602416356 2025-04-02 12:59:16• Docker602420243 2025-09-22 13:56:23= custom.log= laravel.l09L SF (iminny@localhostU scratch_1.jsonV connect.vueV onboard.vue& Hs local liminnyalocalnostconsole EUc crmenutykeposilory.ono(iii crm configurations [EU]¿console IPROD¿ console STAGINGI159115921595159415751070159715981599Y 1600PlavaroundvMa liminnv vselect * from opportunity_stages where opportunity_id = 75 m026 A9 A22 V3 . 103 ^select * trom business_processes where 10 = 6024;select * from business process stages where stage id = 16352:select * trombusiness_process_stageswhere business_process_id = 6024;select x troi staces wereTeall10 = 407.select * from teams where id = 459:SELECTCONCAT(u.id, CASE WHEN U.id = t.owner_id THEN' (owner)' ELSE'' END) AS user_id,U.emaillibda Support Daily • in 3 h 2 mA100% z• Fri 17 Apr 11:58:06AutomatedRenortsCommandTestCascadeAutomated Report Rete Investigating HubSgError Call to a member function businessProcesses on nullWcommana Awaline Aporoval• docker exec docker lamo 1 oho artisan tinkerthe crm has pipeline (business process) and stage id that exists in DB. However there are thousands ofuodates (oart or them. Also the opporunity stace la is uodated all the time. Wnat causes that.<> Codeclaude sonnet 4.060 :-ACVw~L+08Iupdated at Y7079-04-071799.102025-04-02 12:59:162025-04-02 12:59:162025-04-02 12:59:162075-04-02 12.591162025-04-02 12:59:162025-04-02 12:59:162025-09-22 13:56:23o rows retrieved staruing trom 1 in /40 ms (execution: 1u8 ms, Telching: o3z ms)W Windsurf Teams 1594:22 UTF-84 spaces...
|
NULL
|
-9065275057989056140
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileFditViewNavigateCodelaravelRefactonToo PhpStormFileFditViewNavigateCodelaravelRefactonToolsWindowHelpFV faVsco.s v#11894 on JY-18909-automated-reports-ask-iminny K vProject v(C AutomatedReportsService.phpC) SendReportJob.phpC SendReportMailJob.phplorw se cuev.seC ReportController.phpM+ README.mdTokenBuilder.phpC TeamSetupController.phppnp apl.ono• Filesystem.php{o sonar-project.properties© Team.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.php© ActivityLogged.php=test.pyC AutomatedReportsCallbackService.phpC RequestGenerateAskJiminnyReportJob.php‹> Untitled Diagram.xmlC RequestGenerateReportJob.phgC SyncOpportunity.phpT OpportunitySyncTrait.php xC Opportunity.phpus vetur.config.jsM. WEBHOOK FILTERING IMPLE© OpportunityUpdated.phpc) onnortuntvstadendated.nno© EventServiceProvider.php›Ub External LibrariesScratches and ConsolesC OpportunityPendingAiAnalysisAfterStageChanged.phpC RunOpportunityAiAnalysis.phpv M Database ConsolesC ProcessAiAutomationAnalysisResults.php(C) ImportOpportunityBatch.phpT ImportBatchJobTrait.phpV AEUHasattributes.onoC) Service.php© AutomatedReportResult.php© AutomatedReport.phpc consoe -u4 DEAL RISKS [EUT"podcast audio url"Cc W0 results& DI EUT({)BS2 M2M19 ^Services(nJ Outputliii jiminny.business process stages Xv _ Database8 rowsvIX. AutoVdEUo-e business process_id Yoestage id YДQMAФI created at Y« console 1s 475 mso0y4w crm_contigurations 1s 504 ms10049 2020-04-02 12:39.1016351 2025-04-02 12:59:16d jiminny@localhostASF602416352 2025-04-02 12:59:16c moocall602416555 2025-04-02 12:57:10V A PROD602416354 2025-04-02 12•59:16< console ls o0o ms602416355 2025-04-02 12:59:16V L STAGING« console602416356 2025-04-02 12:59:16• Docker602420243 2025-09-22 13:56:23= custom.log= laravel.l09L SF (iminny@localhostU scratch_1.jsonV connect.vueV onboard.vue& Hs local liminnyalocalnostconsole EUc crmenutykeposilory.ono(iii crm configurations [EU]¿console IPROD¿ console STAGINGI159115921595159415751070159715981599Y 1600PlavaroundvMa liminnv vselect * from opportunity_stages where opportunity_id = 75 m026 A9 A22 V3 . 103 ^select * trom business_processes where 10 = 6024;select * from business process stages where stage id = 16352:select * trombusiness_process_stageswhere business_process_id = 6024;select x troi staces wereTeall10 = 407.select * from teams where id = 459:SELECTCONCAT(u.id, CASE WHEN U.id = t.owner_id THEN' (owner)' ELSE'' END) AS user_id,U.emaillibda Support Daily • in 3 h 2 mA100% z• Fri 17 Apr 11:58:06AutomatedRenortsCommandTestCascadeAutomated Report Rete Investigating HubSgError Call to a member function businessProcesses on nullWcommana Awaline Aporoval• docker exec docker lamo 1 oho artisan tinkerthe crm has pipeline (business process) and stage id that exists in DB. However there are thousands ofuodates (oart or them. Also the opporunity stace la is uodated all the time. Wnat causes that.<> Codeclaude sonnet 4.060 :-ACVw~L+08Iupdated at Y7079-04-071799.102025-04-02 12:59:162025-04-02 12:59:162025-04-02 12:59:162075-04-02 12.591162025-04-02 12:59:162025-04-02 12:59:162025-09-22 13:56:23o rows retrieved staruing trom 1 in /40 ms (execution: 1u8 ms, Telching: o3z ms)W Windsurf Teams 1594:22 UTF-84 spaces...
|
NULL
|
|
6718
|
120
|
39
|
2026-04-13T14:02:25.202474+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-13/1776 /Users/lukas/.screenpipe/data/data/2026-04-13/1776088945202_m1.jpg...
|
Boosteroid
|
Boosteroid
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
OrionFileEditViewHistoryBookmarksToolsDevelopWindo OrionFileEditViewHistoryBookmarksToolsDevelopWindowHelplthl•Activity MonitorAll ProcessesProcess Namevoicebankingddeletedtrialdtccdcom.apple.geodASConfigurationSubscriberASConfigurationSubscriberIsdLegacyProfilesSubscriberzshmediaremotedAppSSODaemonScreenSharingSubscriberappstoredzshPasscodeSettingsSubscriberInteractiveLegacyProfilesSubscriberSoftwareUpdateSubscriberaudiomxdgamepolicydSoftwareUpdateSubscriberInteractiveLegacyProfilesSubscriberzshmapssyncdAccountSubscriberzshsirittsdManagedConfigurationFilesSubscriberMEMORY PRESSUREMem..0,0 mo6,0 MB6,0 MB6,0 MB6,0 MB6,0 MB6,0 MB6,0 MB5,9 MB5,9 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,7 MB5,7 MB5,7 MB5,7 MB5,6 MB5,6 MB5,6 MB5,6 MBPhysical Memory:Memory Used:Cached Files:Swap Used:100% [Mon 13 Apr 17:02:24CPUMemoryEnergyDiskNetworkThreadsPorts222222272698853453137625312225413234732228283156702625228325226528PIDOuOTo9081790670889681239891509211992123687920955150343907619207690684501192111920749211010755859211792072489189148921124894921309211616,00 GB12,96 GB2,98 GB1,38 GBApp Memory:Wired Memory:Compressed:Userrunaslukaslukaslukasroot_locationd_rmdlukaslukaslukaslukasrootrootlukas_appstorelukaslukaslukas_rmdaudiomxdrootlukas_rmdlukaslukas_rmdlukaslukas_rmd5,57 GB2,56 GB4,25 GB...
|
NULL
|
-9065051438123793767
|
NULL
|
click
|
ocr
|
NULL
|
OrionFileEditViewHistoryBookmarksToolsDevelopWindo OrionFileEditViewHistoryBookmarksToolsDevelopWindowHelplthl•Activity MonitorAll ProcessesProcess Namevoicebankingddeletedtrialdtccdcom.apple.geodASConfigurationSubscriberASConfigurationSubscriberIsdLegacyProfilesSubscriberzshmediaremotedAppSSODaemonScreenSharingSubscriberappstoredzshPasscodeSettingsSubscriberInteractiveLegacyProfilesSubscriberSoftwareUpdateSubscriberaudiomxdgamepolicydSoftwareUpdateSubscriberInteractiveLegacyProfilesSubscriberzshmapssyncdAccountSubscriberzshsirittsdManagedConfigurationFilesSubscriberMEMORY PRESSUREMem..0,0 mo6,0 MB6,0 MB6,0 MB6,0 MB6,0 MB6,0 MB6,0 MB5,9 MB5,9 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,8 MB5,7 MB5,7 MB5,7 MB5,7 MB5,6 MB5,6 MB5,6 MB5,6 MBPhysical Memory:Memory Used:Cached Files:Swap Used:100% [Mon 13 Apr 17:02:24CPUMemoryEnergyDiskNetworkThreadsPorts222222272698853453137625312225413234732228283156702625228325226528PIDOuOTo9081790670889681239891509211992123687920955150343907619207690684501192111920749211010755859211792072489189148921124894921309211616,00 GB12,96 GB2,98 GB1,38 GBApp Memory:Wired Memory:Compressed:Userrunaslukaslukaslukasroot_locationd_rmdlukaslukaslukaslukasrootrootlukas_appstorelukaslukaslukas_rmdaudiomxdrootlukas_rmdlukaslukas_rmdlukaslukas_rmd5,57 GB2,56 GB4,25 GB...
|
6717
|
|
1554
|
34
|
25
|
2026-04-11T17:39:32.015502+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-11/1775 /Users/lukas/.screenpipe/data/data/2026-04-11/1775929172015_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpall100% CSat 11 Apr 20:39:31-zsh181DOCKER• 8812026-04-11T20:26:10.383131Z2026-04-11T20:26:10.383762Z2026-04-11T20:26:38.337011Z2026-04-11T20:26:55.99268822026-04-11T20:26:57.331771Z2026-04-11T20:27:05.408691Z2026-04-11T20:27:09.664908Z2026-04-11T20:31:05.374969Z2026-04-11T20:31:40.380610Z2026-04-11T20:32:09.668412ZDEV (-zsh)O 82APP (-zsh)• *3-zsh• *4-zsh• 85-zsh86-zshO 87-zshINFOscreenpipe_engine::retention:retention: initialized with 14d retentionINFOscreenpipe:localretention auto-enabled (14 days)INFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=-9215443531147982391, trigger=click)INFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=5606118560581161138, trigger=click)INFOINFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=5606118560581161138, trigger=click)screenpipe_engine::snapshot_compaction: snapshot compaction: found 61 eligible framesINFOINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 61 frames, 9.4MB → 3.3MB (2.8x), 61 JPEGs deletedscreenpipe_engine::server: api_usage_5min: 1 requestsINFOscreenpipe_engine::retention: retention: cleaning up data before 2026-03-28T17:31:40.380594+00:00 (14d retention)INFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 1 eligible framesls -lah ~/.screenpipe/data/data/ | head-20• *8du-sh~/.screenpipe/data/data/*total24drwxr-xr-x5lukasstaff160B 11 Apr 14:52drwxr-xr-xlukasstaff160B 11 Apr 15:03-rw-r--r--@lukasstaff8.0K9 Apr 20:43.DS_Storedrwxr-xr-x14lukasstaff448B 11 Apr 14:532026-04-09drwxr-xr-x98lukasstaff3.1K 11Apr 20:322026-04-1139M/Users/lukas/.screenpipe/data/data/2026-04-0960M/Users/lukas/.screenpipe/data/data/2026-04-11lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ du -sh ~/.screenpipe234M/Users/lukas/.screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ du -sh ~/.screenpipe/234M/Users/lukas/.screenpipe/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $du -sh ~/.screenpipe/*4.0K/Users/lukas/.screenpipe/config.json99M/Users/lukas/.screenpipe/data126M/Users/lukas/.screenpipe/db.sqlite32K/Users/lukas/.screenpipe/db.sqlite-shm8.0M/Users/lukas/.screenpipe/db.sqlite-wal24K/Users/lukas/.screenpipe/pipes132K/Users/lukas/.screenpipe/screenpipe.2026-04-09.0.log132K/Users/lukas/.screenpipe/screenpipe.2026-04-11.0.loglukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ 2026-04-11T20:34:53.551967Z INFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=9017845113719419888, trigger=click)2026-04-11T20:34:56.3796842INFO2026-04-11T20:34:58.704027Zscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=9017845113719419888, trigger=click)INFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=9017845113719419888, trigger=click)2026-04-11T20:36:40.376312ZINFO screenpipe_engine::retention: retention: cleaning up data before 2026-03-28T17:36:40.376264+00:00 (14d retention)2026-04-11T20:36:53.215520ZINFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=1247300013418283538,trigger=click)2026-04-11T20:37:03.5494672INFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=1247300013418283538, trigger=click)2026-04-11T20:37:09.670722ZINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 24 eligible frames2026-04-11T20:37:11.207501ZINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 24 frames, 5.3MB → 1.2MB (4.5x), 24 JPEGs deleted2026-04-11T20:37:44.461189ZINFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=-259750597993296433, trigger=click)2026-04-11T20:37:58.4086082INFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=4847561377089731380, trigger=click)...
|
NULL
|
-9064663974991335539
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpall100% CSat 11 Apr 20:39:31-zsh181DOCKER• 8812026-04-11T20:26:10.383131Z2026-04-11T20:26:10.383762Z2026-04-11T20:26:38.337011Z2026-04-11T20:26:55.99268822026-04-11T20:26:57.331771Z2026-04-11T20:27:05.408691Z2026-04-11T20:27:09.664908Z2026-04-11T20:31:05.374969Z2026-04-11T20:31:40.380610Z2026-04-11T20:32:09.668412ZDEV (-zsh)O 82APP (-zsh)• *3-zsh• *4-zsh• 85-zsh86-zshO 87-zshINFOscreenpipe_engine::retention:retention: initialized with 14d retentionINFOscreenpipe:localretention auto-enabled (14 days)INFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=-9215443531147982391, trigger=click)INFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=5606118560581161138, trigger=click)INFOINFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=5606118560581161138, trigger=click)screenpipe_engine::snapshot_compaction: snapshot compaction: found 61 eligible framesINFOINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 61 frames, 9.4MB → 3.3MB (2.8x), 61 JPEGs deletedscreenpipe_engine::server: api_usage_5min: 1 requestsINFOscreenpipe_engine::retention: retention: cleaning up data before 2026-03-28T17:31:40.380594+00:00 (14d retention)INFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 1 eligible framesls -lah ~/.screenpipe/data/data/ | head-20• *8du-sh~/.screenpipe/data/data/*total24drwxr-xr-x5lukasstaff160B 11 Apr 14:52drwxr-xr-xlukasstaff160B 11 Apr 15:03-rw-r--r--@lukasstaff8.0K9 Apr 20:43.DS_Storedrwxr-xr-x14lukasstaff448B 11 Apr 14:532026-04-09drwxr-xr-x98lukasstaff3.1K 11Apr 20:322026-04-1139M/Users/lukas/.screenpipe/data/data/2026-04-0960M/Users/lukas/.screenpipe/data/data/2026-04-11lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ du -sh ~/.screenpipe234M/Users/lukas/.screenpipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ du -sh ~/.screenpipe/234M/Users/lukas/.screenpipe/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $du -sh ~/.screenpipe/*4.0K/Users/lukas/.screenpipe/config.json99M/Users/lukas/.screenpipe/data126M/Users/lukas/.screenpipe/db.sqlite32K/Users/lukas/.screenpipe/db.sqlite-shm8.0M/Users/lukas/.screenpipe/db.sqlite-wal24K/Users/lukas/.screenpipe/pipes132K/Users/lukas/.screenpipe/screenpipe.2026-04-09.0.log132K/Users/lukas/.screenpipe/screenpipe.2026-04-11.0.loglukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ 2026-04-11T20:34:53.551967Z INFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=9017845113719419888, trigger=click)2026-04-11T20:34:56.3796842INFO2026-04-11T20:34:58.704027Zscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=9017845113719419888, trigger=click)INFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=9017845113719419888, trigger=click)2026-04-11T20:36:40.376312ZINFO screenpipe_engine::retention: retention: cleaning up data before 2026-03-28T17:36:40.376264+00:00 (14d retention)2026-04-11T20:36:53.215520ZINFOscreenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=1247300013418283538,trigger=click)2026-04-11T20:37:03.5494672INFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=1247300013418283538, trigger=click)2026-04-11T20:37:09.670722ZINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: found 24 eligible frames2026-04-11T20:37:11.207501ZINFOscreenpipe_engine::snapshot_compaction: snapshot compaction: 24 frames, 5.3MB → 1.2MB (4.5x), 24 JPEGs deleted2026-04-11T20:37:44.461189ZINFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=-259750597993296433, trigger=click)2026-04-11T20:37:58.4086082INFO screenpipe_engine::event_driven_capture: content dedup: skipping capture for monitor 1 (hash=4847561377089731380, trigger=click)...
|
1553
|
|
41986
|
891
|
26
|
2026-04-17T06:42:06.651369+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776408126651_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileFditViewNavigateCodelaravelRetactonToo PhpStormFileFditViewNavigateCodelaravelRetactonToolsWindowHelpFV faVsco.s v#11894 on JY-18909-automated-reports-ask-jiminnyProject v(C AutomatedReportsService.phpC) SendReportJob.php XC ReportController.php= custom.log= laravel.logL SF (iminny@localhostU scratch_1.isonconnect.vueOnboara,vue(C) RequestGenerateAsl(C) RequestGenerateRel© SendReportJob.php(©) SendReportMailJob.>D CalendarD Crm• DealkisksMalloox> M MeetinaBot> M Middleware> M Streamina→ Team> _ Telephony>D User© BaseProcessingJob.phgc DummyJob.phpC ImportRecallAlRecordin© ImportRemote lrackJobC Job.phpC) JobDispatcher.phpD) JobDispatcherInterfaceC) PurdeSoftDeleted@ppolT SqsVisibilityControl.php› _ Listeners› _ Mailv • Models› D Activity>DAi>_ AskAnything> M Calendar> M Connection> M Contracts>MCrm> M ElasticSearch→ Feature› Opportunity> _ Participant_ PlaybackTheme_ PlaylistScorecard_WebhookC Account.ond© Activity.phpC) Address.ohoC) AiPrompt.phpC) AutomatedReport.phpC) AutomatedReportResultC) Calendar.php© Callimport.phpC) CoachingFeedback.phpC) CoachingFeedbackVisilg CoachingSection.php© CoachingsectionCriterk© CoachingSectionCriterisC) CoachinaSectionFeedb:C CommentAbstract.php• Commentinterface.phpC) Contact.phpC Device.phpTokenBuilder.phpC TeamSetupController.phppnp apl.ono• Filesystem.phpHs local liminnyalocalnosti© AutomatedReportsCommand.php© AskJiminnyReportsController.phpC AutomatedReportsCommandTest.phpC) AutomatedReportsSendCommand.php(C) Team.phpC AutomatedReportsRepository.php© CreateHeldActivityEvent.phpe) TrackProviderinstallled-vent.ono© CreateActivityLoggedevent.php1301546541C UserPilotActivityListener.phpC ActivityLogged.php(©) AutomatedReportsCallbackService.php543(C) RequestGenerateAskJiminnyReportJob.phpC) RequestGenerateReportJob.php543© AutomatedReportResult.phpC) AutomatedReport.phpclass SendReportJob 1mpLements ShouldQueueShouLdBeUnique* ovar intpublic int Stimeout = 120:*** Create a new joo inscance.154654754854955055155255.515545551* dparam string sreporcuuna tne vulb of the report to sends usagespublic function -_construct(private readonly string $reportuvid)$this->onQueue queue: Constants::QUEUE ANALYTICS LOW:562563• public function handle(Loggerinterrace slogger,Jobu1spaucherintertace sJodbspaccher,AUronareokeoorisservice mautonareorevortsserviceD: void &*- 568try «i569$report = $automatedReportsService->getReportResult($this->repont/ 57€} catch (Throwable $e) {571$Loqger->error(self::LOG_PREFIX ." Error getting report result'5725731'UU1d"=> sthis->reportuu1d,—574'exception => se->getMessage@),575576tnrow se;577578579$logger->info(self::LOG PREFIX.'Processing report', [580581"UUld →> sun1s->reportuula'status" => Sreport->getStatusLabelol592ID:58358258"sssPath = sautomatedReportsservice->getMediaPath(sreport):586< console LUiL console (PROD1 X4 console [STAGING]IX: AUTO VHaycroundvWHERE U.team_1d = 862 and sa.provider = 'salestorce':liminny034 41 433 X61 Aselect * from automated_reports where 1d = 36;select ar.treguency, r.x, ar.* trom aucomated_report_results rJoin aucomated_reporus ar on r.report 1d = ar.10where ar.treguency"one_ort";select s.* from activity searches s ioin users u 1.n<->1: ON s.user id = u.id where u.team id = 882:select * from nudges n where n.activity_search_idselect * from teams where created at >2026-03-09'*SELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065SELECT * FROM crm_layout_entitles WHERE crm_layout_1d = 3617:select * from users where team_1d = 1 and name Like "*LUKaS; # 7160SELECI * FRUM TEamS WHERE 10 = 5/51select * from opportunities where team id = 575:SELECT * FROM teamsWHERE name LIKE '%Intearum ESG%': # 1126. 1065select x ToIopportunities where team id = 1126:SELECT * FROM teams WHERE name LIKE "%Base%: # 1125, 1063.select * from opportunities where team id = 1125:v select * from contacts cwhere c.team_1d = 882:SELECT * FROM activities WHERE id = 76822967;SELECT * FROM crm_profiles WHERE user_id = 15440;SELECT * FROM crm profiles WHERE crm confiauration id = 555:SELECT * FROM crm configurations WHERE id = 555:SELECT * FROM users WHERE id = 15440: # team..581, gr. 15440, pl. 3911, act. field 162182~ SELECTCONCAT(u.id, CASE WHEN U.id = t.owner_id THEN ' (owner)' ELSE "' END) AS user_id,u.email,sa.*,t.owner_id FROM social_accounts sannusenc 1l on 1.1d = cacoctahile 10Jol teams t1..n<->1: on t.id = u.team idWHERE u.team_ id = 581 and sa.provider = salesforce":SELECT * FROM automated_report_results order by id desc)select * from featuselect * from team_features where feature id = 40:col ont & From Foamcwhere id = 556:select * from automated_reports where 1d = 54; # 4fdd41f6-dcf0-30d0-b339-734538106044 , L"pdf","podcast"JSELECT * FROM automated_reportresults WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;sellect * From automated iorder by id descSELECT * FROM automatedrepоrт_resuLus WHERE 10 = 19171if (sssPath === nulu)p Logger->errorcselt::LOb_PREFIX' Report media path is null'. [ 58x589 V"UUld" = schis->reportuula,"medlalype => sreport->gecmedlalypel).select * from automesults WHERE report id = 541);uor sedles tet.Accept File &-n& Reecafle iReport media path couldHelper code will nelp IDt lo understand vour Laravelapp code. |/ Generate // Don't show Anymore (42 minutes ago,, 0labl100% (Fri 17 Apr 9:42:06AutomatedReportsCommandTestvCascadeAutomated Report Retiat what stage is the result marked as sent:The status is set to STATUS_SENT Inside SendReportMailJob::handle() - arter a successtul emall sendvia rostmnark.keao senckeoormaly00.0no :1124-143tthe flow is.1. SendReportJob - verifies 53 path exists, dispatches one SendReportMai lJob per recipient2. SendReportMailJob - sends the actual email, then sets status = STATUS SENT + sent at = now()The status update only happens once (guarded by Sreport->status !== STATUS_SENT ) for the firstrecipient whose mail job succeeds, since all recipients share the same reportUuid. This is why sent at isthe tlac the cron relles on — once any mall job succeeas, the wnole report resuit is markea sent anaexc uded from tufure cron runsW1 file +16>Ask anything (&+L)+ <> Code Claude Sonnet 4.6Reject allAccept allwinasun leams5/:28uir-oia 4 spaces...
|
NULL
|
-9064077405856442359
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileFditViewNavigateCodelaravelRetactonToo PhpStormFileFditViewNavigateCodelaravelRetactonToolsWindowHelpFV faVsco.s v#11894 on JY-18909-automated-reports-ask-jiminnyProject v(C AutomatedReportsService.phpC) SendReportJob.php XC ReportController.php= custom.log= laravel.logL SF (iminny@localhostU scratch_1.isonconnect.vueOnboara,vue(C) RequestGenerateAsl(C) RequestGenerateRel© SendReportJob.php(©) SendReportMailJob.>D CalendarD Crm• DealkisksMalloox> M MeetinaBot> M Middleware> M Streamina→ Team> _ Telephony>D User© BaseProcessingJob.phgc DummyJob.phpC ImportRecallAlRecordin© ImportRemote lrackJobC Job.phpC) JobDispatcher.phpD) JobDispatcherInterfaceC) PurdeSoftDeleted@ppolT SqsVisibilityControl.php› _ Listeners› _ Mailv • Models› D Activity>DAi>_ AskAnything> M Calendar> M Connection> M Contracts>MCrm> M ElasticSearch→ Feature› Opportunity> _ Participant_ PlaybackTheme_ PlaylistScorecard_WebhookC Account.ond© Activity.phpC) Address.ohoC) AiPrompt.phpC) AutomatedReport.phpC) AutomatedReportResultC) Calendar.php© Callimport.phpC) CoachingFeedback.phpC) CoachingFeedbackVisilg CoachingSection.php© CoachingsectionCriterk© CoachingSectionCriterisC) CoachinaSectionFeedb:C CommentAbstract.php• Commentinterface.phpC) Contact.phpC Device.phpTokenBuilder.phpC TeamSetupController.phppnp apl.ono• Filesystem.phpHs local liminnyalocalnosti© AutomatedReportsCommand.php© AskJiminnyReportsController.phpC AutomatedReportsCommandTest.phpC) AutomatedReportsSendCommand.php(C) Team.phpC AutomatedReportsRepository.php© CreateHeldActivityEvent.phpe) TrackProviderinstallled-vent.ono© CreateActivityLoggedevent.php1301546541C UserPilotActivityListener.phpC ActivityLogged.php(©) AutomatedReportsCallbackService.php543(C) RequestGenerateAskJiminnyReportJob.phpC) RequestGenerateReportJob.php543© AutomatedReportResult.phpC) AutomatedReport.phpclass SendReportJob 1mpLements ShouldQueueShouLdBeUnique* ovar intpublic int Stimeout = 120:*** Create a new joo inscance.154654754854955055155255.515545551* dparam string sreporcuuna tne vulb of the report to sends usagespublic function -_construct(private readonly string $reportuvid)$this->onQueue queue: Constants::QUEUE ANALYTICS LOW:562563• public function handle(Loggerinterrace slogger,Jobu1spaucherintertace sJodbspaccher,AUronareokeoorisservice mautonareorevortsserviceD: void &*- 568try «i569$report = $automatedReportsService->getReportResult($this->repont/ 57€} catch (Throwable $e) {571$Loqger->error(self::LOG_PREFIX ." Error getting report result'5725731'UU1d"=> sthis->reportuu1d,—574'exception => se->getMessage@),575576tnrow se;577578579$logger->info(self::LOG PREFIX.'Processing report', [580581"UUld →> sun1s->reportuula'status" => Sreport->getStatusLabelol592ID:58358258"sssPath = sautomatedReportsservice->getMediaPath(sreport):586< console LUiL console (PROD1 X4 console [STAGING]IX: AUTO VHaycroundvWHERE U.team_1d = 862 and sa.provider = 'salestorce':liminny034 41 433 X61 Aselect * from automated_reports where 1d = 36;select ar.treguency, r.x, ar.* trom aucomated_report_results rJoin aucomated_reporus ar on r.report 1d = ar.10where ar.treguency"one_ort";select s.* from activity searches s ioin users u 1.n<->1: ON s.user id = u.id where u.team id = 882:select * from nudges n where n.activity_search_idselect * from teams where created at >2026-03-09'*SELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065SELECT * FROM crm_layout_entitles WHERE crm_layout_1d = 3617:select * from users where team_1d = 1 and name Like "*LUKaS; # 7160SELECI * FRUM TEamS WHERE 10 = 5/51select * from opportunities where team id = 575:SELECT * FROM teamsWHERE name LIKE '%Intearum ESG%': # 1126. 1065select x ToIopportunities where team id = 1126:SELECT * FROM teams WHERE name LIKE "%Base%: # 1125, 1063.select * from opportunities where team id = 1125:v select * from contacts cwhere c.team_1d = 882:SELECT * FROM activities WHERE id = 76822967;SELECT * FROM crm_profiles WHERE user_id = 15440;SELECT * FROM crm profiles WHERE crm confiauration id = 555:SELECT * FROM crm configurations WHERE id = 555:SELECT * FROM users WHERE id = 15440: # team..581, gr. 15440, pl. 3911, act. field 162182~ SELECTCONCAT(u.id, CASE WHEN U.id = t.owner_id THEN ' (owner)' ELSE "' END) AS user_id,u.email,sa.*,t.owner_id FROM social_accounts sannusenc 1l on 1.1d = cacoctahile 10Jol teams t1..n<->1: on t.id = u.team idWHERE u.team_ id = 581 and sa.provider = salesforce":SELECT * FROM automated_report_results order by id desc)select * from featuselect * from team_features where feature id = 40:col ont & From Foamcwhere id = 556:select * from automated_reports where 1d = 54; # 4fdd41f6-dcf0-30d0-b339-734538106044 , L"pdf","podcast"JSELECT * FROM automated_reportresults WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;sellect * From automated iorder by id descSELECT * FROM automatedrepоrт_resuLus WHERE 10 = 19171if (sssPath === nulu)p Logger->errorcselt::LOb_PREFIX' Report media path is null'. [ 58x589 V"UUld" = schis->reportuula,"medlalype => sreport->gecmedlalypel).select * from automesults WHERE report id = 541);uor sedles tet.Accept File &-n& Reecafle iReport media path couldHelper code will nelp IDt lo understand vour Laravelapp code. |/ Generate // Don't show Anymore (42 minutes ago,, 0labl100% (Fri 17 Apr 9:42:06AutomatedReportsCommandTestvCascadeAutomated Report Retiat what stage is the result marked as sent:The status is set to STATUS_SENT Inside SendReportMailJob::handle() - arter a successtul emall sendvia rostmnark.keao senckeoormaly00.0no :1124-143tthe flow is.1. SendReportJob - verifies 53 path exists, dispatches one SendReportMai lJob per recipient2. SendReportMailJob - sends the actual email, then sets status = STATUS SENT + sent at = now()The status update only happens once (guarded by Sreport->status !== STATUS_SENT ) for the firstrecipient whose mail job succeeds, since all recipients share the same reportUuid. This is why sent at isthe tlac the cron relles on — once any mall job succeeas, the wnole report resuit is markea sent anaexc uded from tufure cron runsW1 file +16>Ask anything (&+L)+ <> Code Claude Sonnet 4.6Reject allAccept allwinasun leams5/:28uir-oia 4 spaces...
|
41983
|
|
46688
|
983
|
25
|
2026-04-17T10:44:49.354458+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776422689354_m1.jpg...
|
PhpStorm
|
faVsco.js – ~/jiminny/app/front-end/src/components faVsco.js – ~/jiminny/app/front-end/src/components/connect/connect.vue...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20692-fix-integration-app-token-auth-response-change, menu","depth":5,"help_text":"Git Branch: JY-20692-fix-integration-app-token-auth-response-change","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,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AutomatedReportsCommandTest","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AutomatedReportsCommandTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AutomatedReportsCommandTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"cachedStages","depth":4,"value":"cachedStages","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2/4","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"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},"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},"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},"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},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"33","depth":4,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\n }\n}","depth":4,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\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},"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},"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},"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},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","depth":4,"value":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"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},"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},"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},"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},"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},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9063734413195222639
|
-8178086449155632858
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
46686
|
|
46969
|
NULL
|
0
|
2026-04-17T10:58:18.924024+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423498924_m1.jpg...
|
PhpStorm
|
faVsco.js – ~/jiminny/app/front-end/src/components faVsco.js – ~/jiminny/app/front-end/src/components/connect/connect.vue...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20692-fix-integration-app-token-auth-response-change, menu","depth":5,"help_text":"Git Branch: JY-20692-fix-integration-app-token-auth-response-change","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,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AutomatedReportsCommandTest","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AutomatedReportsCommandTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AutomatedReportsCommandTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"cachedStages","depth":4,"value":"cachedStages","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2/4","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"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},"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},"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},"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},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"33","depth":4,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\n }\n}","depth":4,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\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},"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},"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},"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},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","depth":4,"value":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"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},"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},"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},"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},"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},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9063734413195222639
|
-8178086449155632858
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
|
46973
|
990
|
1
|
2026-04-17T10:59:14.871593+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423554871_m2.jpg...
|
PhpStorm
|
faVsco.js – ~/jiminny/app/front-end/src/components faVsco.js – ~/jiminny/app/front-end/src/components/connect/connect.vue...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.03046875,"top":0.017361112,"width":0.0453125,"height":0.022222223},"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20692-fix-integration-app-token-auth-response-change, menu","depth":5,"bounds":{"left":0.07578125,"top":0.017361112,"width":0.15898438,"height":0.022222223},"help_text":"Git Branch: JY-20692-fix-integration-app-token-auth-response-change","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.78515625,"top":0.017361112,"width":0.01328125,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AutomatedReportsCommandTest","depth":6,"bounds":{"left":0.803125,"top":0.017361112,"width":0.09765625,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AutomatedReportsCommandTest'","depth":6,"bounds":{"left":0.9007813,"top":0.017361112,"width":0.01328125,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AutomatedReportsCommandTest'","depth":6,"bounds":{"left":0.9140625,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9273437,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.96015626,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9734375,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9867188,"top":0.017361112,"width":0.013281226,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.12382813,"top":0.22083333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.13867188,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"cachedStages","depth":4,"bounds":{"left":0.1515625,"top":0.22013889,"width":0.0515625,"height":0.013888889},"value":"cachedStages","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.21367188,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.22539063,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.23554687,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.24570313,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.23320313,"top":1.0,"width":0.00859375,"height":0.0},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.23320313,"top":1.0,"width":0.00859375,"height":0.0},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.23320313,"top":1.0,"width":0.00859375,"height":0.0},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2/4","depth":4,"bounds":{"left":0.26171875,"top":0.21944444,"width":0.030078124,"height":0.015277778},"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.29179686,"top":0.21875,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"bounds":{"left":0.30195314,"top":0.21875,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"bounds":{"left":0.31210938,"top":0.21875,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"bounds":{"left":0.32226562,"top":0.21875,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"bounds":{"left":0.38320312,"top":0.21875,"width":0.01015625,"height":0.016666668},"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.23320313,"top":1.0,"width":0.049609374,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.3421875,"top":0.24583334,"width":0.012109375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.35664064,"top":0.24583334,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.3683594,"top":0.24583334,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.3816406,"top":0.24444444,"width":0.00859375,"height":0.015972223},"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.39023438,"top":0.24444444,"width":0.008203125,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\n }\n}","depth":4,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.049609374,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.69921875,"top":0.10902778,"width":0.00859375,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.7097656,"top":0.10763889,"width":0.00859375,"height":0.015972223},"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.71835935,"top":0.10763889,"width":0.008203125,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","depth":4,"value":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.0140625,"top":0.041666668,"width":0.028515626,"height":0.021527778},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9063734413195222639
|
-8178086449155632858
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
|
46974
|
989
|
1
|
2026-04-17T10:59:15.423442+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423555423_m1.jpg...
|
PhpStorm
|
faVsco.js – ~/jiminny/app/front-end/src/components faVsco.js – ~/jiminny/app/front-end/src/components/connect/connect.vue...
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20692-fix-integration-app-token-auth-response-change, menu","depth":5,"help_text":"Git Branch: JY-20692-fix-integration-app-token-auth-response-change","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,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AutomatedReportsCommandTest","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AutomatedReportsCommandTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AutomatedReportsCommandTest'","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"cachedStages","depth":4,"value":"cachedStages","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2/4","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"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},"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},"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},"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},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"33","depth":4,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\n }\n}","depth":4,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\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},"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},"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},"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},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","depth":4,"value":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"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},"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},"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},"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},"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},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9063734413195222639
|
-8178086449155632858
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
|
46975
|
990
|
2
|
2026-04-17T10:59:45.258953+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423585258_m2.jpg...
|
PhpStorm
|
faVsco.js – ~/jiminny/app/front-end/src/components faVsco.js – ~/jiminny/app/front-end/src/components/connect/connect.vue...
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.03046875,"top":0.017361112,"width":0.0453125,"height":0.022222223},"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20692-fix-integration-app-token-auth-response-change, menu","depth":5,"bounds":{"left":0.07578125,"top":0.017361112,"width":0.15898438,"height":0.022222223},"help_text":"Git Branch: JY-20692-fix-integration-app-token-auth-response-change","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.78515625,"top":0.017361112,"width":0.01328125,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AutomatedReportsCommandTest","depth":6,"bounds":{"left":0.803125,"top":0.017361112,"width":0.09765625,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AutomatedReportsCommandTest'","depth":6,"bounds":{"left":0.9007813,"top":0.017361112,"width":0.01328125,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AutomatedReportsCommandTest'","depth":6,"bounds":{"left":0.9140625,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9273437,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.96015626,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9734375,"top":0.017361112,"width":0.01328125,"height":0.022222223},"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.9867188,"top":0.017361112,"width":0.013281226,"height":0.022222223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.12382813,"top":0.22083333,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.13867188,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"cachedStages","depth":4,"bounds":{"left":0.1515625,"top":0.22013889,"width":0.0515625,"height":0.013888889},"value":"cachedStages","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.21367188,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.22539063,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.23554687,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.24570313,"top":0.22013889,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.23320313,"top":1.0,"width":0.00859375,"height":0.0},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.23320313,"top":1.0,"width":0.00859375,"height":0.0},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.23320313,"top":1.0,"width":0.00859375,"height":0.0},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2/4","depth":4,"bounds":{"left":0.26171875,"top":0.21944444,"width":0.030078124,"height":0.015277778},"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.29179686,"top":0.21875,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"bounds":{"left":0.30195314,"top":0.21875,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"bounds":{"left":0.31210938,"top":0.21875,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"bounds":{"left":0.32226562,"top":0.21875,"width":0.01015625,"height":0.016666668},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"bounds":{"left":0.38320312,"top":0.21875,"width":0.01015625,"height":0.016666668},"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.23320313,"top":1.0,"width":0.049609374,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.3421875,"top":0.24583334,"width":0.012109375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.35664064,"top":0.24583334,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.3683594,"top":0.24583334,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.3816406,"top":0.24444444,"width":0.00859375,"height":0.015972223},"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.39023438,"top":0.24444444,"width":0.008203125,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\n }\n}","depth":4,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits;\n\nuse Carbon\\Carbon;\nuse HubSpot\\Client\\Crm\\Deals\\Model\\CollectionResponseAssociatedId;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Models\\Account;\nuse Exception;\nuse Jiminny\\Component\\DealInsights\\Forecast\\Forecast;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Models\\Opportunity;\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Services\\Crm\\Hubspot\\DealFieldsService;\nuse Jiminny\\Services\\Crm\\Hubspot\\OpportunitySyncStrategy\\HubspotSingleSyncStrategy;\nuse Jiminny\\Services\\Crm\\Hubspot\\WebhookSyncBatchProcessor;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\n\n/**\n * Optimized sync methods for better performance\n * These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains\n */\ntrait OpportunitySyncTrait\n{\n private const int BATCH_SIZE = 100;\n private const int BATCH_PROCESS_SIZE = 800;\n\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected DealFieldsService $dealFieldsService;\n\n private ?array $cachedClosedDealStages = null;\n private array $cachedBusinessProcesses = [];\n private array $cachedStages = [];\n\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n $parameters['config'] = $this->config;\n $syncCount = 0;\n $reportedTotal = 0;\n $lastSyncedId = [];\n\n try {\n foreach ($strategies as $strategyName => $syncStrategy) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .\n $strategyName\n );\n\n $total = 0;\n $lastId = null;\n $buffer = [];\n\n // HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies\n foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {\n $buffer[] = $hsOpportunity;\n\n // process every 800 rows (fits < 1 000 association limit)\n if (\\count($buffer) >= self::BATCH_PROCESS_SIZE) {\n $syncCount += $this->processOpportunityBatch($buffer);\n $buffer = [];\n }\n }\n\n // leftovers\n if ($buffer) {\n $syncCount += $this->processOpportunityBatch($buffer);\n }\n\n $reportedTotal += $total;\n $lastSyncedId = $lastId;\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException | CrmException $e) {\n $this->handleSyncException($e, $parameters);\n }\n\n $this->logger->info(\n '[HubSpot] Synced opportunities',\n [\n 'team' => $this->team->getId(),\n 'sync_count' => $syncCount,\n 'total' => $reportedTotal,\n 'last_synced_id' => $lastSyncedId,\n ]\n );\n\n return $reportedTotal;\n }\n\n private function handleSyncException(\\Throwable $e, array $parameters): void\n {\n if (($parameters['since'] ?? null) instanceof Carbon) {\n $parameters['since'] = $parameters['since']->toDateTimeString();\n }\n $parameters['config'] = $this->config->getId();\n\n $this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [\n 'teamId' => $this->team->getUuid(),\n 'parameters' => $parameters,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = [\n 'config' => $this->config,\n 'crm_id' => $crmId,\n ];\n\n try {\n if (! $strategy instanceof HubspotSingleSyncStrategy) {\n throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');\n }\n\n $hsOpportunity = $strategy->fetchOpportunity($parameters);\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->getUuid(),\n 'crmId' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);\n\n return $this->importOrUpdateOpportunity($hsOpportunity);\n }\n\n /**\n * Process webhook-collected opportunity batches.\n *\n * Drains Redis sets containing company CRM IDs collected from webhook events\n * and dispatches ImportOpportunityBatch jobs for batch processing.\n *\n * @return int Number of opportunity IDs dispatched to jobs\n */\n public function batchSyncOpportunities(): int\n {\n $configId = $this->team->getCrmConfiguration()->getId();\n\n return $this->batchProcessor->processBatchesForObjectType(\n WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,\n $configId\n );\n }\n\n /**\n * Import a batch of opportunities by their CRM IDs.\n * Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().\n *\n * @param array<string> $crmIds HubSpot deal CRM IDs\n *\n * @return array{success: array, failed_ids: array, errors?: array<string, string>}\n */\n public function importOpportunityBatchByIds(array $crmIds): array\n {\n $fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);\n\n $allDeals = [];\n foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {\n $deals = $this->client->getOpportunitiesByIds($chunk, $fields);\n foreach ($deals as $deal) {\n $allDeals[] = $deal;\n }\n }\n\n // IDs not returned by HubSpot are likely deleted or inaccessible deals.\n // These are not failures — retrying won't bring them back.\n $fetchedIds = array_map('strval', array_column($allDeals, 'id'));\n $notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));\n\n if (! empty($notFoundIds)) {\n $this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [\n 'teamId' => $this->team->getId(),\n 'notFoundCount' => \\count($notFoundIds),\n 'notFoundIds' => $notFoundIds,\n 'requestedCount' => \\count($crmIds),\n 'fetchedCount' => \\count($allDeals),\n ]);\n }\n\n if (empty($allDeals)) {\n return ['success' => [], 'failed_ids' => []];\n }\n\n return $this->importOpportunityBatch($allDeals);\n }\n\n private function getClosedDealStages(): array\n {\n if ($this->cachedClosedDealStages !== null) {\n return $this->cachedClosedDealStages;\n }\n\n $stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);\n $data = [\n 'lost' => [],\n 'won' => [],\n ];\n\n foreach ($stages as $stage) {\n if ($stage->probability == 0.00) {\n $data['lost'][] = $stage->crm_provider_id;\n }\n if ($stage->probability == 100.00) {\n $data['won'][] = $stage->crm_provider_id;\n }\n }\n\n $this->cachedClosedDealStages = $data;\n\n return $data;\n }\n\n /**\n * Import deals into the database with pre-fetched associations.\n *\n * API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT\n * caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()\n * where Laravel retries the whole job with backoff. After all retries exhausted,\n * failed() requeues all IDs to Redis.\n *\n * The per-deal loop catches exceptions individually. A deal can end up in three states:\n * - success: imported/updated successfully\n * - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)\n * These are permanent issues — retrying won't fix them.\n * - skipped (null): missing dependencies (no account, unknown pipeline/stage).\n * This is acceptable — the deal cannot be imported until those exist.\n */\n private function importOpportunityBatch(array $deals): array\n {\n $syncedOpportunities = [\n 'success' => [],\n 'failed_ids' => [],\n ];\n $dealIds = array_column($deals, 'id');\n\n // Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the\n // queue job retries the whole batch and eventually requeues all deal IDs back to Redis.\n try {\n $companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');\n $contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');\n\n $associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);\n\n $existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(\n $this->config,\n array_map('strval', $dealIds)\n );\n $existingCrmIdSet = array_flip($existingCrmIds);\n } catch (\\Throwable $e) {\n $this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [\n 'teamId' => $this->team->getId(),\n 'dealCount' => count($dealIds),\n 'error' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n\n foreach ($deals as $deal) {\n try {\n $deal['associations'] = $this->prepareAssociationsForOpportunity(\n $deal['id'],\n $companyAssociations,\n $contactAssociations,\n $associationsData\n );\n\n $syncedOpportunity = $this->importOrUpdateOpportunity(\n $deal,\n isset($existingCrmIdSet[(string) $deal['id']])\n );\n if ($syncedOpportunity) {\n $syncedOpportunities['success'][] = $syncedOpportunity;\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [\n 'teamId' => $this->team->getId(),\n 'crmId' => $deal['id'],\n 'error' => $e->getMessage(),\n ]);\n $syncedOpportunities['failed_ids'][] = $deal['id'];\n $syncedOpportunities['errors'][$deal['id']] = $e->getMessage();\n }\n }\n\n return $syncedOpportunities;\n }\n\n /**\n * Prepare associated entities for opportunities with optimized batch processing\n * Returns structured data with CRM ID to DB ID mappings for each opportunity\n */\n private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array\n {\n // Step 1: Collect all unique company and contact IDs from associations\n $allCompanyIds = $this->flattenAssociationIds($companyAssociations);\n $allContactIds = $this->flattenAssociationIds($contactAssociations);\n\n // Step 2: Batch sync missing entities and get CRM ID to DB ID mappings\n $companyIdMappings = [];\n $contactIdMappings = [];\n\n if (! empty($allCompanyIds)) {\n $companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);\n }\n\n if (! empty($allContactIds)) {\n $contactIdMappings = $this->prepareAssociatedContacts($allContactIds);\n }\n\n return [\n 'company_id_mappings' => $companyIdMappings,\n 'contact_id_mappings' => $contactIdMappings,\n ];\n }\n\n /**\n * Flatten association data to get unique IDs\n */\n private function flattenAssociationIds(array $associations): array\n {\n $ids = [];\n foreach ($associations as $dealAssociations) {\n if (is_array($dealAssociations)) {\n foreach ($dealAssociations as $id) {\n $ids[$id] = true;\n }\n }\n }\n\n return array_keys($ids);\n }\n\n /**\n * Batch sync missing accounts\n */\n private function prepareAssociatedAccounts(array $companyIds): array\n {\n // Find which accounts already exist\n $existingAccounts = $this->crmEntityRepository\n ->findAccountsByExternalIds($this->config, $companyIds);\n\n $existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();\n\n $existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {\n return [$account->getCrmProviderId() => $account->getId()];\n })->toArray();\n\n $missingCompanyIds = array_diff($companyIds, $existingCompanyIds);\n\n if (empty($missingCompanyIds)) {\n return $existingAccountsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [\n 'teamId' => $this->team->getUuid(),\n 'total_companies' => count($companyIds),\n 'existing_companies' => count($existingCompanyIds),\n 'missing_companies' => count($missingCompanyIds),\n ]);\n\n // we already have limit on opportunity ids count\n // Initialize variable before try block\n $syncedAccountsData = [];\n\n try {\n $syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [\n 'size' => count($missingCompanyIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedAccountsData = [];\n }\n\n return $existingAccountsData + $syncedAccountsData;\n }\n\n /**\n * Prepare associated contacts - find existing and sync missing ones\n * Returns mapping of CRM ID to DB ID\n */\n private function prepareAssociatedContacts(array $contactIds): array\n {\n // Find which contacts already exist\n $existingContacts = $this->crmEntityRepository\n ->findContactsByExternalIds($this->config, $contactIds);\n\n $existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();\n\n // Create mapping for existing contacts\n $existingContactsData = $existingContacts->mapWithKeys(function ($contact) {\n return [$contact->getCrmProviderId() => $contact->getId()];\n })->toArray();\n\n $missingContactIds = array_diff($contactIds, $existingContactIds);\n\n if (empty($missingContactIds)) {\n return $existingContactsData;\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [\n 'teamId' => $this->team->getUuid(),\n 'total_contacts' => count($contactIds),\n 'existing_contacts' => count($existingContactIds),\n 'missing_contacts' => count($missingContactIds),\n ]);\n\n // Sync missing contacts using batch API\n try {\n $syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [\n 'size' => count($missingContactIds),\n 'error' => $e->getMessage(),\n ]);\n $syncedContactsData = [];\n }\n\n return $existingContactsData + $syncedContactsData;\n }\n\n private function batchSyncCrmObjects(string $objectType, array $crmIds): array\n {\n $syncObjects = [];\n $crmObjectIds = array_values($crmIds);\n\n foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {\n try {\n $objects = $objectType === 'companies' ?\n $this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :\n $this->client->getContactsByIds($chunk, $this->getContactFields());\n\n foreach ($objects as $objectId => $objectData) {\n $this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);\n }\n\n $this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [\n 'requested_count' => count($chunk),\n 'synced_count' => count($objects),\n ]);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [\n 'ids' => $chunk,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n return $syncObjects;\n }\n\n private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void\n {\n try {\n $object = $objectType === 'companies' ?\n $this->importAccount($objectData) :\n $this->importContact($objectData);\n\n if ($object) {\n $syncObjects[$object->getCrmProviderId()] = $object->getId();\n }\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [\n 'id' => $objectId,\n 'error' => $e->getMessage(),\n ]);\n }\n }\n\n /**\n * Prepare associations for a single opportunity\n *\n * The return value is an array with the following structure:\n * [\n * 'companies' => [\n * $companyCrmId => $companyId,\n * ...\n * ],\n * 'contacts' => [\n * $contactCrmId => $contactId,\n * ...\n * ],\n * 'account_id' => $accountId,\n * ]\n */\n private function prepareAssociationsForOpportunity(\n string $oppCrmId,\n array $companyAssociations,\n array $contactAssociations,\n array $associationsData\n ): array {\n $associations = [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n\n $oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];\n foreach ($oppCompanyIds as $companyCrmId) {\n if (isset($associationsData['company_id_mappings'][$companyCrmId])) {\n $associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];\n\n // Set primary account (first company becomes primary account)\n if ($associations['account_id'] === null) {\n $associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];\n }\n }\n }\n\n $oppContactIds = $contactAssociations[$oppCrmId] ?? [];\n foreach ($oppContactIds as $contactCrmId) {\n if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {\n $associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];\n }\n }\n\n return $associations;\n }\n\n /**\n * Update only associations for an opportunity\n */\n private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void\n {\n // Update contact associations\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n // Update company (account) associations\n $this->updateOpportunityAccount($opportunity, $associations['account_id']);\n }\n\n /**\n * Remove all contact associations from an opportunity\n */\n private function removeAllOpportunityContacts(Opportunity $opportunity): void\n {\n $currentCount = (int) $opportunity->contacts()->count();\n\n if ($currentCount > 0) {\n $opportunity->contacts()->detach();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_count' => $currentCount,\n ]);\n }\n }\n\n private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void\n {\n if ($accountId === null) {\n // No account ID provided - keep current account\n return;\n }\n\n $currentAccountId = $opportunity->getAccountId();\n\n // Only update if account has changed\n if ($currentAccountId !== $accountId) {\n $opportunity->account_id = $accountId;\n $opportunity->save();\n\n $this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [\n 'opportunity_id' => $opportunity->getId(),\n 'old_account_id' => $currentAccountId,\n 'new_account_id' => $accountId,\n ]);\n }\n }\n\n /**\n * Find existing opportunities by external IDs (OPTIMIZED VERSION)\n * Uses batch query for better performance\n */\n private function findExistingOpportunities(array $crmIds): Collection\n {\n return $this->crmEntityRepository\n ->findOpportunitiesByExternalIds($this->config, $crmIds);\n }\n\n private function processOpportunityBatch(array $opportunities): int\n {\n $syncedOpportunities = $this->importOpportunityBatch($opportunities);\n\n return count($syncedOpportunities['success'] ?? []);\n }\n\n /**\n * Convert single deal associations from HubSpot format to internal format\n * Handles both HubSpot SDK objects and array formats\n *\n * @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed\n *\n * @return array Processed associations with DB IDs\n */\n private function convertDealAssociations(array $opportunityAssociations): array\n {\n $associations = $this->initializeAssociationsStructure();\n\n if (empty($opportunityAssociations)) {\n return $associations;\n }\n\n $associationIds = $this->extractAssociationIds($opportunityAssociations);\n\n $this->processCompanyAssociations($associationIds, $associations);\n $this->processContactAssociations($associationIds, $associations);\n\n return $associations;\n }\n\n private function initializeAssociationsStructure(): array\n {\n return [\n 'companies' => [],\n 'contacts' => [],\n 'account_id' => null, // Primary account for opportunity\n ];\n }\n\n private function extractAssociationIds(array $opportunityAssociations): array\n {\n $associationIds = [];\n\n foreach ($opportunityAssociations as $type => $associationData) {\n if (! empty($associationData)) {\n $associationIds[$type] = $this->convertSingleDealAssociations($associationData);\n }\n }\n\n return $associationIds;\n }\n\n private function processCompanyAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['companies'])) {\n return;\n }\n\n $companyId = $associationIds['companies'][0];\n $account = $this->findOrSyncAccount($companyId);\n\n if ($account instanceof Account) {\n $associations['companies'][$companyId] = $account->getId();\n $associations['account_id'] = $account->getId();\n }\n }\n\n private function processContactAssociations(array $associationIds, array &$associations): void\n {\n if (empty($associationIds['contacts'])) {\n return;\n }\n\n foreach ($associationIds['contacts'] as $contactId) {\n $contact = $this->findOrSyncContact($contactId);\n\n if ($contact instanceof Contact) {\n $associations['contacts'][$contactId] = $contact->getId();\n }\n }\n }\n\n private function findOrSyncAccount(string $companyId): ?Account\n {\n $account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);\n\n if (! $account instanceof Account) {\n $account = $this->syncAccount($companyId);\n }\n\n return $account;\n }\n\n private function findOrSyncContact(string $contactId): ?Contact\n {\n $contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);\n\n if (! $contact instanceof Contact) {\n $contact = $this->syncContact($contactId);\n }\n\n return $contact;\n }\n\n private function convertSingleDealAssociations($opportunityAssociations = null): array\n {\n $associationData = [];\n\n if ($opportunityAssociations === null) {\n return $associationData;\n }\n\n // Handle array input (from extractAssociationIds)\n if (is_array($opportunityAssociations)) {\n return $opportunityAssociations;\n }\n\n // Handle CollectionResponseAssociatedId object\n if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {\n foreach ($opportunityAssociations->getResults() as $association) {\n $associationData[] = $association->getId();\n }\n }\n\n return $associationData;\n }\n\n private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity\n {\n if (empty($crmData['properties'])) {\n return null;\n }\n\n $crmId = (string) $crmData['id'];\n $properties = $crmData['properties'];\n $associations = $crmData['associations'] ?? [];\n\n $opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(\n $this->config,\n $crmId\n );\n\n if ($opportunityExists) {\n return $this->updateOpportunity($crmId, $properties, $associations);\n } else {\n return $this->createOpportunity($crmId, $properties, $associations);\n }\n }\n\n /**\n * Create new opportunity\n */\n private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n if (! $accountId) {\n return null;\n }\n\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n if (! $businessProcess) {\n return null;\n }\n\n $stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);\n if (! $stage) {\n return null;\n }\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->importOpportunityContacts($opportunity, $associations['contacts']);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * Update existing opportunity\n */\n private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity\n {\n $accountId = $this->resolveAccountId($associations);\n $businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);\n $stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;\n\n $data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);\n\n $attributes = [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmId,\n ];\n\n $values = array_merge($attributes, $data);\n $opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);\n\n $this->importExternalFieldData($properties, $opportunity->getId());\n $this->updateOpportunityAssociations($opportunity, $associations);\n\n return $opportunity;\n }\n\n private function resolveAccountId(array $associations): ?int\n {\n if (! empty($associations['accountId'])) {\n return $associations['accountId'];\n }\n\n if (empty($associations)) {\n return null;\n }\n\n // we can't resolve multiple account ids (currently SDK returns one company)\n foreach ($associations['companies'] as $accountId) {\n return $accountId;\n }\n\n return null;\n }\n\n private function buildOpportunityData(\n array $properties,\n ?int $accountId,\n ?BusinessProcess $businessProcess,\n ?Stage $stage\n ): array {\n $ownerId = null;\n $profile = null;\n if (! empty($properties['hubspot_owner_id'])) {\n $ownerId = $properties['hubspot_owner_id'];\n $profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);\n }\n\n $name = 'Unknown';\n if (isset($properties['dealname'])) {\n $name = mb_strimwidth($properties['dealname'], 0, 128);\n }\n\n $amount = $this->resolveAmount($properties);\n $currency = $properties['deal_currency_code'] ?? null;\n\n $closeDate = null;\n if (! empty($properties['closedate'])) {\n $closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');\n }\n\n $remotelyCreatedAt = null;\n if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {\n $date = $this->parseCleanDatetime($properties['createdate']);\n $remotelyCreatedAt = $date?->format('Y-m-d H:i:s');\n }\n\n $closedStages = $this->getClosedDealStages();\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $data = [\n 'team_id' => $this->team->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => $name,\n 'value' => ! empty($amount) ? $amount : null,\n 'currency_code' => CurrencyFormatter::formatCode($currency),\n 'close_date' => $closeDate,\n 'is_closed' => $isWon || $isLost,\n 'is_won' => $isWon,\n 'remotely_created_at' => $remotelyCreatedAt,\n 'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),\n 'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),\n ];\n\n if ($accountId) {\n $data['account_id'] = $accountId;\n }\n\n if ($stage) {\n $data['stage_id'] = $stage->id;\n }\n\n if ($businessProcess) {\n $recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);\n if ($recordType) {\n $data['record_type_id'] = $recordType->id;\n }\n }\n\n return $data;\n }\n\n private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess\n {\n if ($pipelineId === null) {\n return null;\n }\n\n if (isset($this->cachedBusinessProcesses[$pipelineId])) {\n return $this->cachedBusinessProcesses[$pipelineId];\n }\n\n $businessProcess = $this->getBusinessProcess($pipelineId);\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->importStages();\n $businessProcess = $this->getBusinessProcess($pipelineId);\n }\n\n if (! $businessProcess instanceof BusinessProcess) {\n $this->logger->info(\n '[HubSpot] Deal is not attached to a pipeline',\n [\n 'pipeline' => $pipelineId]\n );\n }\n\n $this->cachedBusinessProcesses[$pipelineId] = $businessProcess;\n\n return $businessProcess;\n }\n\n private function getBusinessProcess(string $pipelineId): ?BusinessProcess\n {\n return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);\n }\n\n private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage\n {\n if (empty($stageId)) {\n return null;\n }\n\n $cacheKey = $businessProcess->getId() . ':' . $stageId;\n if (isset($this->cachedStages[$cacheKey])) {\n return $this->cachedStages[$cacheKey];\n }\n\n $stage = $this->crmEntityRepository->getPipelineStageByConditions(\n $businessProcess,\n [\n 'crm_provider_id' => $stageId,\n 'type' => Stage::TYPE_OPPORTUNITY,\n ]\n );\n\n if ($stage === null) {\n $this->importStages(null, $stageId);\n }\n\n if ($stage === null) {\n $this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);\n }\n\n $this->cachedStages[$cacheKey] = $stage;\n\n return $stage;\n }\n\n private function resolveAmount(array $properties): ?string\n {\n $amount = null;\n if (! empty($properties['amount'])) {\n $amount = str_replace(',', '', $properties['amount']);\n }\n\n if ($this->config->hasDefaultCurrencyFieldSet()) {\n $valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();\n $amount = $properties[$valueFieldName] ?? $amount;\n }\n\n return $amount;\n }\n\n private function parseCleanDatetime(string $datetime): ?Carbon\n {\n // Treat pre-1980 values as invalid\n $minValidDate = Carbon::parse('1980-01-01 00:00:00');\n\n try {\n $date = Carbon::parse($datetime);\n\n if ($minValidDate->gt($date)) {\n return null;\n }\n\n return $date;\n } catch (Exception) {\n return null; // On parse error, treat as null\n }\n }\n\n private function resolveDealProbability(?string $stageProbability): int\n {\n if ($stageProbability === null) {\n return 0;\n }\n\n $probability = (float) $stageProbability;\n\n return $probability > 1 ? 0 : (int) ($probability * 100);\n }\n\n private function resolveForecastCategory(?string $forecastCategory): string\n {\n if (! $forecastCategory) {\n return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;\n }\n\n $forecastCategory = str_replace('_', ' ', $forecastCategory);\n\n return ucwords(strtolower($forecastCategory));\n }\n\n private function importExternalFieldData(array $properties, int $opportunityId): void\n {\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);\n }\n\n private function importOpportunityContacts(Opportunity $opportunity, array $associations): void\n {\n // Handle empty or missing contact associations\n if (empty($associations)) {\n // Remove all existing contact associations if none provided\n $this->removeAllOpportunityContacts($opportunity);\n\n return;\n }\n\n // Use differential sync approach for better performance and accuracy\n $this->syncOpportunityContactsDifferential($opportunity, $associations);\n }\n\n /**\n * Sync opportunity contacts using differential approach\n * This compares current vs new associations and only makes necessary changes\n */\n private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void\n {\n $currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);\n $contactAssociationIds = array_keys($contactAssociations);\n\n $contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);\n $contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);\n\n if (empty($contactsToAdd) && empty($contactsToRemove)) {\n return;\n }\n\n $this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);\n\n $this->removeContactAssociations($opportunity, $contactsToRemove);\n $this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);\n }\n\n private function getCurrentContactCrmIds(Opportunity $opportunity): array\n {\n return $opportunity->contacts()\n ->pluck('contacts.crm_provider_id')\n ->toArray();\n }\n\n private function logContactAssociationChanges(\n Opportunity $opportunity,\n array $currentContactCrmIds,\n array $contactAssociations,\n array $contactsToAdd,\n array $contactsToRemove\n ): void {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [\n 'opportunity_id' => $opportunity->getId(),\n 'current_contacts' => $currentContactCrmIds,\n 'new_contacts' => $contactAssociations,\n 'contacts_to_add' => $contactsToAdd,\n 'contacts_to_remove' => $contactsToRemove,\n ]);\n }\n\n private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void\n {\n if (empty($contactsToRemove)) {\n return;\n }\n\n $contactsToDetach = $opportunity->contacts()\n ->whereIn('contacts.crm_provider_id', $contactsToRemove)\n ->pluck('contacts.id')\n ->toArray();\n\n if (! empty($contactsToDetach)) {\n $opportunity->contacts()->detach($contactsToDetach);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'removed_contact_crm_ids' => $contactsToRemove,\n 'removed_contact_count' => count($contactsToDetach),\n ]);\n }\n }\n\n private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void\n {\n if (empty($contactsToAdd)) {\n return;\n }\n\n $contactsAdded = [];\n foreach ($contactsToAdd as $crmId) {\n $id = $contactAssociations[$crmId];\n\n if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {\n $contactsAdded[] = $crmId;\n }\n }\n\n $this->logAddedContacts($opportunity, $contactsAdded);\n }\n\n private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool\n {\n try {\n $contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);\n\n if (! $contact) {\n return false;\n }\n\n return $this->performContactAttachment($opportunity, $contact, $crmId);\n } catch (\\Throwable $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [\n 'opportunity_id' => $opportunity->getId(),\n 'contact_crm_id' => $crmId,\n 'error' => $e->getMessage(),\n ]);\n\n return false;\n }\n }\n\n private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool\n {\n try {\n $opportunity->contacts()->attach($contact->getId(), [\n 'crm_provider_id' => $crmId,\n ]);\n\n return true;\n } catch (\\Illuminate\\Database\\QueryException $e) {\n if (str_contains($e->getMessage(), 'Duplicate entry')) {\n $this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [\n 'contact_id' => $contact->getId(),\n 'contact_crm_id' => $crmId,\n 'opportunity_id' => $opportunity->getId(),\n ]);\n\n return false;\n }\n\n throw $e;\n }\n }\n\n private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void\n {\n if (! empty($contactsAdded)) {\n $this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [\n 'opportunity_id' => $opportunity->getId(),\n 'contacts_to_add_count' => count($contactsAdded),\n 'added_contact_crm_ids' => $contactsAdded,\n 'added_contacts_count' => count($contactsAdded),\n ]);\n }\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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.049609374,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.69921875,"top":0.10902778,"width":0.00859375,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.7097656,"top":0.10763889,"width":0.00859375,"height":0.015972223},"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.71835935,"top":0.10763889,"width":0.008203125,"height":0.015972223},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","depth":4,"value":"<template>\n <WelcomeLayout\n title=\"Account disconnected\"\n textPosition=\"center\"\n :icon=\"faUnlink\"\n :class=\"$style.layout\"\n >\n <div :class=\"$style.container\" v-if=\"providersLoaded\">\n <p>\n <strong>\n It looks like your {{ localProvider.displayName }} account has become\n disconnected\n </strong>\n </p>\n <p :class=\"$style.small\">Please re-connect to continue</p>\n <p v-if=\"isInIframe\">\n We'll open the {{ localProvider.displayName }} authentication in a new\n tab. Please return here and refresh the page once complete\n </p>\n\n <GoogleLikeButton\n v-if=\"localProvider.viaIntegrationApp && crmTokenLoaded\"\n as=\"a\"\n :key=\"localProvider.name\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n @click=\"integrationAppOnClick\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n <GoogleLikeButton\n v-if=\"!localProvider.viaIntegrationApp\"\n as=\"a\"\n :key=\"localProvider.name\"\n :href=\"`/auth/redirect/${localProvider.name}`\"\n :target=\"target\"\n :brand-logo=\"localProvider.name\"\n :class=\"$style.connectButton\"\n >\n Sign in with {{ localProvider.displayName }}\n </GoogleLikeButton>\n </div>\n <BuildInfo />\n\n <KioskBanner />\n </WelcomeLayout>\n</template>\n\n<script>\nimport window from \"window\";\nimport axios from \"axios\";\nimport { faUnlink } from \"@fortawesome/pro-regular-svg-icons\";\nimport isInIframe from \"@/utils/isInIframe\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport KioskBanner from \"@/components/shared/KioskBanner/KioskBanner.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport GoogleLikeButton from \"@/components/shared/Buttons/GoogleLikeButton.vue\";\nimport { showSnackbarError, normalizeError } from \"@/utils/index\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\n\nexport default {\n name: \"ConnectPage\",\n components: {\n BuildInfo,\n KioskBanner,\n WelcomeLayout,\n GoogleLikeButton,\n },\n data() {\n return {\n ...window.connectData,\n crmToken: null,\n faUnlink,\n isInIframe,\n providers: [],\n providersLoaded: false,\n crmTokenLoaded: false,\n };\n },\n computed: {\n localProvider() {\n return this.providers.find((e) => e.name === this.provider);\n },\n target() {\n return this.isInIframe ? \"_blank\" : null;\n },\n },\n created() {\n this.getProviders();\n },\n mounted() {\n this.showErrors();\n },\n watch: {\n providersLoaded() {\n if (this.providersLoaded) {\n this.prepareIntegrationAppConnection();\n }\n },\n },\n methods: {\n showErrors() {\n if (!this.error) return;\n\n showSnackbarError(this.error, undefined, undefined, false);\n },\n unwrapEntityResponse({ data }) {\n return data.map(({ icon, name, displayName, viaIntegrationApp }) => {\n return { icon, name, displayName, viaIntegrationApp };\n });\n },\n async getProviders() {\n try {\n const response = await axios.get(\"/api/v1/connect-providers\");\n this.providers = this.unwrapEntityResponse(response);\n this.providersLoaded = true;\n } catch {\n showSnackbarError(\n \"An error occurred, while loading form data (connect providers).\",\n );\n }\n },\n async prepareIntegrationAppConnection() {\n if (this.localProvider.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n this.crmToken = response.data.token;\n this.crmTokenLoaded = true;\n } catch (error) {\n console.log(error);\n showSnackbarError(\n `An error occurred while preparing the page.\n Try refreshing, if the error persists get in touch with the Jiminny team.`,\n );\n }\n }\n },\n async integrationAppOnClick() {\n console.log('[IntegrationApp] integrationAppOnClick called');\n const integrationApp = new IntegrationAppClient({\n token: this.crmToken,\n });\n\n const connection = await integrationApp\n .integration(this.localProvider.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n }).catch((err) => {\n console.log('[IntegrationApp] openNewConnection rejected:', err);\n return null;\n });\n\n console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));\n\n // [IntegrationApp] openNewConnection resolved: {\n // \"id\":\"69e0b41a67d0068c2ca0b48e\",\n // \"name\":\"Zoho CRM\",\n // \"userId\":\"1ece66c8-feb1-4df1-b321-21607daf4623\",\n // \"tenantId\":\"69e0b3faef3e7b6248189289\",\n // \"isTest\":false,\n // \"connected\":true,\n // \"state\":\"READY\",\n // \"errors\":[],\n // \"integrationId\":\"66fe6c913202f3a165e3c14d\",\n // \"externalAppId\":\"6671653e7e2d642e4e41b0fa\",\n // \"authOptionKey\":\"\",\n // \"createdAt\":\"2026-04-16T10:04:10.420Z\",\n // \"updatedAt\":\"2026-04-16T10:04:10.575Z\",\n // \"retryAttempts\":0,\n // \"isDeactivated\":false\n // }\n\n if (connection && connection.disconnected !== true && connection.connected !== false) {\n console.log('[IntegrationApp] connection condition matched');\n try {\n const saveRequest = await axios.post(\n \"/api/v1/integration-app-connect\",\n );\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n window.location = \"/dashboard\";\n return;\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n }\n },\n },\n};\n</script>\n\n<style module lang=\"less\" src=\"./connect.less\"></style>","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.0140625,"top":0.041666668,"width":0.028515626,"height":0.021527778},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"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.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.01015625,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-9063734413195222639
|
-8178086449155632858
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20692-fix-integration- Project: faVsco.js, menu
JY-20692-fix-integration-app-[API_KEY], menu
Start Listening for PHP Debug Connections
AutomatedReportsCommandTest
Run 'AutomatedReportsCommandTest'
Debug 'AutomatedReportsCommandTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
cachedStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
2/4
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
33
2
19
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\Hubspot\ServiceTraits;
use Carbon\Carbon;
use HubSpot\Client\Crm\Deals\Model\CollectionResponseAssociatedId;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Models\Account;
use Exception;
use Jiminny\Component\DealInsights\Forecast\Forecast;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Exceptions\CrmException;
use Jiminny\Models\Opportunity;
use Illuminate\Support\Collection;
use Jiminny\Models\Stage;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Services\Crm\Hubspot\DealFieldsService;
use Jiminny\Services\Crm\Hubspot\OpportunitySyncStrategy\HubspotSingleSyncStrategy;
use Jiminny\Services\Crm\Hubspot\WebhookSyncBatchProcessor;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
/**
* Optimized sync methods for better performance
* These methods can be integrated into SyncCrmEntitiesTrait for significant performance gains
*/
trait OpportunitySyncTrait
{
private const int BATCH_SIZE = 100;
private const int BATCH_PROCESS_SIZE = 800;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected DealFieldsService $dealFieldsService;
private ?array $cachedClosedDealStages = null;
private array $cachedBusinessProcesses = [];
private array $cachedStages = [];
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$parameters['config'] = $this->config;
$syncCount = 0;
$reportedTotal = 0;
$lastSyncedId = [];
try {
foreach ($strategies as $strategyName => $syncStrategy) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Syncing opportunities using strategy: ' .
$strategyName
);
$total = 0;
$lastId = null;
$buffer = [];
// HubspotWebhookBatchSyncStrategy returns empty generator, this is for other strategies
foreach ($syncStrategy->fetchOpportunities($parameters, $total, $lastId) as $hsOpportunity) {
$buffer[] = $hsOpportunity;
// process every 800 rows (fits < 1 000 association limit)
if (\count($buffer) >= self::BATCH_PROCESS_SIZE) {
$syncCount += $this->processOpportunityBatch($buffer);
$buffer = [];
}
}
// leftovers
if ($buffer) {
$syncCount += $this->processOpportunityBatch($buffer);
}
$reportedTotal += $total;
$lastSyncedId = $lastId;
}
} catch (\HubSpot\Client\Crm\Deals\ApiException | CrmException $e) {
$this->handleSyncException($e, $parameters);
}
$this->logger->info(
'[HubSpot] Synced opportunities',
[
'team' => $this->team->getId(),
'sync_count' => $syncCount,
'total' => $reportedTotal,
'last_synced_id' => $lastSyncedId,
]
);
return $reportedTotal;
}
private function handleSyncException(\Throwable $e, array $parameters): void
{
if (($parameters['since'] ?? null) instanceof Carbon) {
$parameters['since'] = $parameters['since']->toDateTimeString();
}
$parameters['config'] = $this->config->getId();
$this->logger->warning('[' . $this->getDisplayName() . '] Sync opportunities failed', [
'teamId' => $this->team->getUuid(),
'parameters' => $parameters,
'reason' => $e->getMessage(),
]);
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = [
'config' => $this->config,
'crm_id' => $crmId,
];
try {
if (! $strategy instanceof HubspotSingleSyncStrategy) {
throw new InvalidArgumentException('Strategy must by HubspotSingleSyncStrategy');
}
$hsOpportunity = $strategy->fetchOpportunity($parameters);
} catch (\HubSpot\Client\Crm\Deals\ApiException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->getUuid(),
'crmId' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
$hsOpportunity['associations'] = $this->convertDealAssociations($hsOpportunity['associations'] ?? []);
return $this->importOrUpdateOpportunity($hsOpportunity);
}
/**
* Process webhook-collected opportunity batches.
*
* Drains Redis sets containing company CRM IDs collected from webhook events
* and dispatches ImportOpportunityBatch jobs for batch processing.
*
* @return int Number of opportunity IDs dispatched to jobs
*/
public function batchSyncOpportunities(): int
{
$configId = $this->team->getCrmConfiguration()->getId();
return $this->batchProcessor->processBatchesForObjectType(
WebhookSyncBatchProcessor::OBJECT_TYPE_DEAL,
$configId
);
}
/**
* Import a batch of opportunities by their CRM IDs.
* Fetches opportunity data from HubSpot API and delegates to importOpportunityBatch().
*
* @param array<string> $crmIds HubSpot deal CRM IDs
*
* @return array{success: array, failed_ids: array, errors?: array<string, string>}
*/
public function importOpportunityBatchByIds(array $crmIds): array
{
$fields = $this->dealFieldsService->getFieldsForConfiguration($this->config);
$allDeals = [];
foreach (array_chunk($crmIds, self::BATCH_SIZE) as $chunk) {
$deals = $this->client->getOpportunitiesByIds($chunk, $fields);
foreach ($deals as $deal) {
$allDeals[] = $deal;
}
}
// IDs not returned by HubSpot are likely deleted or inaccessible deals.
// These are not failures — retrying won't bring them back.
$fetchedIds = array_map('strval', array_column($allDeals, 'id'));
$notFoundIds = array_values(array_diff(array_map('strval', $crmIds), $fetchedIds));
if (! empty($notFoundIds)) {
$this->logger->info('[' . $this->getDisplayName() . '] CRM IDs not found in HubSpot (likely deleted)', [
'teamId' => $this->team->getId(),
'notFoundCount' => \count($notFoundIds),
'notFoundIds' => $notFoundIds,
'requestedCount' => \count($crmIds),
'fetchedCount' => \count($allDeals),
]);
}
if (empty($allDeals)) {
return ['success' => [], 'failed_ids' => []];
}
return $this->importOpportunityBatch($allDeals);
}
private function getClosedDealStages(): array
{
if ($this->cachedClosedDealStages !== null) {
return $this->cachedClosedDealStages;
}
$stages = $this->crmEntityRepository->getOpportunityClosedStages($this->config);
$data = [
'lost' => [],
'won' => [],
];
foreach ($stages as $stage) {
if ($stage->probability == 0.00) {
$data['lost'][] = $stage->crm_provider_id;
}
if ($stage->probability == 100.00) {
$data['won'][] = $stage->crm_provider_id;
}
}
$this->cachedClosedDealStages = $data;
return $data;
}
/**
* Import deals into the database with pre-fetched associations.
*
* API calls here (getAssociationsData, getExistingOpportunityCrmIds) are NOT
* caught — if they throw, the exception propagates to ImportOpportunityBatch::handle()
* where Laravel retries the whole job with backoff. After all retries exhausted,
* failed() requeues all IDs to Redis.
*
* The per-deal loop catches exceptions individually. A deal can end up in three states:
* - success: imported/updated successfully
* - failed_ids: exception thrown (DB constraint violation, corrupt data, etc.)
* These are permanent issues — retrying won't fix them.
* - skipped (null): missing dependencies (no account, unknown pipeline/stage).
* This is acceptable — the deal cannot be imported until those exist.
*/
private function importOpportunityBatch(array $deals): array
{
$syncedOpportunities = [
'success' => [],
'failed_ids' => [],
];
$dealIds = array_column($deals, 'id');
// Shared association/existing-ID preparation is batch-level state. If it fails, rethrow so the
// queue job retries the whole batch and eventually requeues all deal IDs back to Redis.
try {
$companyAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'companies');
$contactAssociations = $this->client->getAssociationsData($dealIds, 'deals', 'contacts');
$associationsData = $this->prepareAssociatedEntities($companyAssociations, $contactAssociations);
$existingCrmIds = $this->crmEntityRepository->getExistingOpportunityCrmIds(
$this->config,
array_map('strval', $dealIds)
);
$existingCrmIdSet = array_flip($existingCrmIds);
} catch (\Throwable $e) {
$this->logger->error('[' . $this->getDisplayName() . '] Failed to fetch associations or existing IDs', [
'teamId' => $this->team->getId(),
'dealCount' => count($dealIds),
'error' => $e->getMessage(),
]);
throw $e;
}
foreach ($deals as $deal) {
try {
$deal['associations'] = $this->prepareAssociationsForOpportunity(
$deal['id'],
$companyAssociations,
$contactAssociations,
$associationsData
);
$syncedOpportunity = $this->importOrUpdateOpportunity(
$deal,
isset($existingCrmIdSet[(string) $deal['id']])
);
if ($syncedOpportunity) {
$syncedOpportunities['success'][] = $syncedOpportunity;
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import opportunity', [
'teamId' => $this->team->getId(),
'crmId' => $deal['id'],
'error' => $e->getMessage(),
]);
$syncedOpportunities['failed_ids'][] = $deal['id'];
$syncedOpportunities['errors'][$deal['id']] = $e->getMessage();
}
}
return $syncedOpportunities;
}
/**
* Prepare associated entities for opportunities with optimized batch processing
* Returns structured data with CRM ID to DB ID mappings for each opportunity
*/
private function prepareAssociatedEntities(array $companyAssociations, array $contactAssociations): array
{
// Step 1: Collect all unique company and contact IDs from associations
$allCompanyIds = $this->flattenAssociationIds($companyAssociations);
$allContactIds = $this->flattenAssociationIds($contactAssociations);
// Step 2: Batch sync missing entities and get CRM ID to DB ID mappings
$companyIdMappings = [];
$contactIdMappings = [];
if (! empty($allCompanyIds)) {
$companyIdMappings = $this->prepareAssociatedAccounts($allCompanyIds);
}
if (! empty($allContactIds)) {
$contactIdMappings = $this->prepareAssociatedContacts($allContactIds);
}
return [
'company_id_mappings' => $companyIdMappings,
'contact_id_mappings' => $contactIdMappings,
];
}
/**
* Flatten association data to get unique IDs
*/
private function flattenAssociationIds(array $associations): array
{
$ids = [];
foreach ($associations as $dealAssociations) {
if (is_array($dealAssociations)) {
foreach ($dealAssociations as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
/**
* Batch sync missing accounts
*/
private function prepareAssociatedAccounts(array $companyIds): array
{
// Find which accounts already exist
$existingAccounts = $this->crmEntityRepository
->findAccountsByExternalIds($this->config, $companyIds);
$existingCompanyIds = $existingAccounts->pluck('crm_provider_id')->toArray();
$existingAccountsData = $existingAccounts->mapWithKeys(function ($account) {
return [$account->getCrmProviderId() => $account->getId()];
})->toArray();
$missingCompanyIds = array_diff($companyIds, $existingCompanyIds);
if (empty($missingCompanyIds)) {
return $existingAccountsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing accounts', [
'teamId' => $this->team->getUuid(),
'total_companies' => count($companyIds),
'existing_companies' => count($existingCompanyIds),
'missing_companies' => count($missingCompanyIds),
]);
// we already have limit on opportunity ids count
// Initialize variable before try block
$syncedAccountsData = [];
try {
$syncedAccountsData = $this->batchSyncCrmObjects('companies', $missingCompanyIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing accounts', [
'size' => count($missingCompanyIds),
'error' => $e->getMessage(),
]);
$syncedAccountsData = [];
}
return $existingAccountsData + $syncedAccountsData;
}
/**
* Prepare associated contacts - find existing and sync missing ones
* Returns mapping of CRM ID to DB ID
*/
private function prepareAssociatedContacts(array $contactIds): array
{
// Find which contacts already exist
$existingContacts = $this->crmEntityRepository
->findContactsByExternalIds($this->config, $contactIds);
$existingContactIds = $existingContacts->pluck('crm_provider_id')->toArray();
// Create mapping for existing contacts
$existingContactsData = $existingContacts->mapWithKeys(function ($contact) {
return [$contact->getCrmProviderId() => $contact->getId()];
})->toArray();
$missingContactIds = array_diff($contactIds, $existingContactIds);
if (empty($missingContactIds)) {
return $existingContactsData;
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch syncing missing contacts', [
'teamId' => $this->team->getUuid(),
'total_contacts' => count($contactIds),
'existing_contacts' => count($existingContactIds),
'missing_contacts' => count($missingContactIds),
]);
// Sync missing contacts using batch API
try {
$syncedContactsData = $this->batchSyncCrmObjects('contacts', $missingContactIds);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to sync missing contacts', [
'size' => count($missingContactIds),
'error' => $e->getMessage(),
]);
$syncedContactsData = [];
}
return $existingContactsData + $syncedContactsData;
}
private function batchSyncCrmObjects(string $objectType, array $crmIds): array
{
$syncObjects = [];
$crmObjectIds = array_values($crmIds);
foreach (array_chunk($crmObjectIds, self::BATCH_SIZE) as $chunk) {
try {
$objects = $objectType === 'companies' ?
$this->client->getCompaniesByIds($chunk, $this->getCompanyFields()) :
$this->client->getContactsByIds($chunk, $this->getContactFields());
foreach ($objects as $objectId => $objectData) {
$this->importCrmObject($objectType, (string) $objectId, $objectData, $syncObjects);
}
$this->logger->info('[' . $this->getDisplayName() . '] Batch synced ' . $objectType, [
'requested_count' => count($chunk),
'synced_count' => count($objects),
]);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Batch ' . $objectType . ' sync failed', [
'ids' => $chunk,
'error' => $e->getMessage(),
]);
}
}
return $syncObjects;
}
private function importCrmObject(string $objectType, string $objectId, mixed $objectData, array &$syncObjects): void
{
try {
$object = $objectType === 'companies' ?
$this->importAccount($objectData) :
$this->importContact($objectData);
if ($object) {
$syncObjects[$object->getCrmProviderId()] = $object->getId();
}
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to import batch ' . $objectType, [
'id' => $objectId,
'error' => $e->getMessage(),
]);
}
}
/**
* Prepare associations for a single opportunity
*
* The return value is an array with the following structure:
* [
* 'companies' => [
* $companyCrmId => $companyId,
* ...
* ],
* 'contacts' => [
* $contactCrmId => $contactId,
* ...
* ],
* 'account_id' => $accountId,
* ]
*/
private function prepareAssociationsForOpportunity(
string $oppCrmId,
array $companyAssociations,
array $contactAssociations,
array $associationsData
): array {
$associations = [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
$oppCompanyIds = $companyAssociations[$oppCrmId] ?? [];
foreach ($oppCompanyIds as $companyCrmId) {
if (isset($associationsData['company_id_mappings'][$companyCrmId])) {
$associations['companies'][$companyCrmId] = $associationsData['company_id_mappings'][$companyCrmId];
// Set primary account (first company becomes primary account)
if ($associations['account_id'] === null) {
$associations['account_id'] = $associationsData['company_id_mappings'][$companyCrmId];
}
}
}
$oppContactIds = $contactAssociations[$oppCrmId] ?? [];
foreach ($oppContactIds as $contactCrmId) {
if (isset($associationsData['contact_id_mappings'][$contactCrmId])) {
$associations['contacts'][$contactCrmId] = $associationsData['contact_id_mappings'][$contactCrmId];
}
}
return $associations;
}
/**
* Update only associations for an opportunity
*/
private function updateOpportunityAssociations(Opportunity $opportunity, array $associations): void
{
// Update contact associations
$this->importOpportunityContacts($opportunity, $associations['contacts']);
// Update company (account) associations
$this->updateOpportunityAccount($opportunity, $associations['account_id']);
}
/**
* Remove all contact associations from an opportunity
*/
private function removeAllOpportunityContacts(Opportunity $opportunity): void
{
$currentCount = (int) $opportunity->contacts()->count();
if ($currentCount > 0) {
$opportunity->contacts()->detach();
$this->logger->info('[' . $this->getDisplayName() . '] Removed all contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_count' => $currentCount,
]);
}
}
private function updateOpportunityAccount(Opportunity $opportunity, ?int $accountId): void
{
if ($accountId === null) {
// No account ID provided - keep current account
return;
}
$currentAccountId = $opportunity->getAccountId();
// Only update if account has changed
if ($currentAccountId !== $accountId) {
$opportunity->account_id = $accountId;
$opportunity->save();
$this->logger->info('[' . $this->getDisplayName() . '] Updated opportunity account association', [
'opportunity_id' => $opportunity->getId(),
'old_account_id' => $currentAccountId,
'new_account_id' => $accountId,
]);
}
}
/**
* Find existing opportunities by external IDs (OPTIMIZED VERSION)
* Uses batch query for better performance
*/
private function findExistingOpportunities(array $crmIds): Collection
{
return $this->crmEntityRepository
->findOpportunitiesByExternalIds($this->config, $crmIds);
}
private function processOpportunityBatch(array $opportunities): int
{
$syncedOpportunities = $this->importOpportunityBatch($opportunities);
return count($syncedOpportunities['success'] ?? []);
}
/**
* Convert single deal associations from HubSpot format to internal format
* Handles both HubSpot SDK objects and array formats
*
* @param array $opportunityAssociations Raw associations from HubSpot API or pre-processed
*
* @return array Processed associations with DB IDs
*/
private function convertDealAssociations(array $opportunityAssociations): array
{
$associations = $this->initializeAssociationsStructure();
if (empty($opportunityAssociations)) {
return $associations;
}
$associationIds = $this->extractAssociationIds($opportunityAssociations);
$this->processCompanyAssociations($associationIds, $associations);
$this->processContactAssociations($associationIds, $associations);
return $associations;
}
private function initializeAssociationsStructure(): array
{
return [
'companies' => [],
'contacts' => [],
'account_id' => null, // Primary account for opportunity
];
}
private function extractAssociationIds(array $opportunityAssociations): array
{
$associationIds = [];
foreach ($opportunityAssociations as $type => $associationData) {
if (! empty($associationData)) {
$associationIds[$type] = $this->convertSingleDealAssociations($associationData);
}
}
return $associationIds;
}
private function processCompanyAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['companies'])) {
return;
}
$companyId = $associationIds['companies'][0];
$account = $this->findOrSyncAccount($companyId);
if ($account instanceof Account) {
$associations['companies'][$companyId] = $account->getId();
$associations['account_id'] = $account->getId();
}
}
private function processContactAssociations(array $associationIds, array &$associations): void
{
if (empty($associationIds['contacts'])) {
return;
}
foreach ($associationIds['contacts'] as $contactId) {
$contact = $this->findOrSyncContact($contactId);
if ($contact instanceof Contact) {
$associations['contacts'][$contactId] = $contact->getId();
}
}
}
private function findOrSyncAccount(string $companyId): ?Account
{
$account = $this->crmEntityRepository->findAccountByExternalId($this->config, $companyId);
if (! $account instanceof Account) {
$account = $this->syncAccount($companyId);
}
return $account;
}
private function findOrSyncContact(string $contactId): ?Contact
{
$contact = $this->crmEntityRepository->findContactByExternalId($this->config, $contactId);
if (! $contact instanceof Contact) {
$contact = $this->syncContact($contactId);
}
return $contact;
}
private function convertSingleDealAssociations($opportunityAssociations = null): array
{
$associationData = [];
if ($opportunityAssociations === null) {
return $associationData;
}
// Handle array input (from extractAssociationIds)
if (is_array($opportunityAssociations)) {
return $opportunityAssociations;
}
// Handle CollectionResponseAssociatedId object
if ($opportunityAssociations instanceof CollectionResponseAssociatedId) {
foreach ($opportunityAssociations->getResults() as $association) {
$associationData[] = $association->getId();
}
}
return $associationData;
}
private function importOrUpdateOpportunity($crmData, ?bool $exists = null): ?Opportunity
{
if (empty($crmData['properties'])) {
return null;
}
$crmId = (string) $crmData['id'];
$properties = $crmData['properties'];
$associations = $crmData['associations'] ?? [];
$opportunityExists = $exists ?? (bool) $this->crmEntityRepository->findOpportunityByExternalId(
$this->config,
$crmId
);
if ($opportunityExists) {
return $this->updateOpportunity($crmId, $properties, $associations);
} else {
return $this->createOpportunity($crmId, $properties, $associations);
}
}
/**
* Create new opportunity
*/
private function createOpportunity(string $crmId, array $properties, array $associations): ?Opportunity
{
$accountId = $this->resolveAccountId($associations);
if (! $accountId) {
return null;
}
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
if (! $businessProcess) {
return null;
}
$stage = $this->resolveStage($businessProcess, $properties['dealstage'] ?? null);
if (! $stage) {
return null;
}
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->importOpportunityContacts($opportunity, $associations['contacts']);
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* Update existing opportunity
*/
private function updateOpportunity(string $crmId, array $properties, array $associations): Opportunity
{
$accountId = $this->resolveAccountId($associations);
$businessProcess = $this->resolveBusinessProcess($properties['pipeline'] ?? null);
$stage = $businessProcess ? $this->resolveStage($businessProcess, $properties['dealstage'] ?? null) : null;
$data = $this->buildOpportunityData($properties, $accountId, $businessProcess, $stage);
$attributes = [
'crm_configuration_id' => $this->config->getId(),
'crm_provider_id' => $crmId,
];
$values = array_merge($attributes, $data);
$opportunity = $this->crmEntityRepository->upsertOpportunity($attributes, $values);
$this->importExternalFieldData($properties, $opportunity->getId());
$this->updateOpportunityAssociations($opportunity, $associations);
return $opportunity;
}
private function resolveAccountId(array $associations): ?int
{
if (! empty($associations['accountId'])) {
return $associations['accountId'];
}
if (empty($associations)) {
return null;
}
// we can't resolve multiple account ids (currently SDK returns one company)
foreach ($associations['companies'] as $accountId) {
return $accountId;
}
return null;
}
private function buildOpportunityData(
array $properties,
?int $accountId,
?BusinessProcess $businessProcess,
?Stage $stage
): array {
$ownerId = null;
$profile = null;
if (! empty($properties['hubspot_owner_id'])) {
$ownerId = $properties['hubspot_owner_id'];
$profile = $this->crmEntityRepository->findProfileByExternalId($this->config, (string) $ownerId);
}
$name = 'Unknown';
if (isset($properties['dealname'])) {
$name = mb_strimwidth($properties['dealname'], 0, 128);
}
$amount = $this->resolveAmount($properties);
$currency = $properties['deal_currency_code'] ?? null;
$closeDate = null;
if (! empty($properties['closedate'])) {
$closeDate = Carbon::parse($properties['closedate'])->format('Y-m-d');
}
$remotelyCreatedAt = null;
if (! empty($properties['createdate']) && strtotime($properties['createdate'])) {
$date = $this->parseCleanDatetime($properties['createdate']);
$remotelyCreatedAt = $date?->format('Y-m-d H:i:s');
}
$closedStages = $this->getClosedDealStages();
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$data = [
'team_id' => $this->team->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => $name,
'value' => ! empty($amount) ? $amount : null,
'currency_code' => CurrencyFormatter::formatCode($currency),
'close_date' => $closeDate,
'is_closed' => $isWon || $isLost,
'is_won' => $isWon,
'remotely_created_at' => $remotelyCreatedAt,
'probability' => $this->resolveDealProbability($properties['hs_deal_stage_probability']),
'forecast_category' => $this->resolveForecastCategory($properties['hs_manual_forecast_category']),
];
if ($accountId) {
$data['account_id'] = $accountId;
}
if ($stage) {
$data['stage_id'] = $stage->id;
}
if ($businessProcess) {
$recordType = $this->crmEntityRepository->getBusinessProcessRecordType($businessProcess);
if ($recordType) {
$data['record_type_id'] = $recordType->id;
}
}
return $data;
}
private function resolveBusinessProcess(?string $pipelineId): ?BusinessProcess
{
if ($pipelineId === null) {
return null;
}
if (isset($this->cachedBusinessProcesses[$pipelineId])) {
return $this->cachedBusinessProcesses[$pipelineId];
}
$businessProcess = $this->getBusinessProcess($pipelineId);
if (! $businessProcess instanceof BusinessProcess) {
$this->importStages();
$businessProcess = $this->getBusinessProcess($pipelineId);
}
if (! $businessProcess instanceof BusinessProcess) {
$this->logger->info(
'[HubSpot] Deal is not attached to a pipeline',
[
'pipeline' => $pipelineId]
);
}
$this->cachedBusinessProcesses[$pipelineId] = $businessProcess;
return $businessProcess;
}
private function getBusinessProcess(string $pipelineId): ?BusinessProcess
{
return $this->crmEntityRepository->findBusinessProcessesByExternalId($this->config, $pipelineId);
}
private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage
{
if (empty($stageId)) {
return null;
}
$cacheKey = $businessProcess->getId() . ':' . $stageId;
if (isset($this->cachedStages[$cacheKey])) {
return $this->cachedStages[$cacheKey];
}
$stage = $this->crmEntityRepository->getPipelineStageByConditions(
$businessProcess,
[
'crm_provider_id' => $stageId,
'type' => Stage::TYPE_OPPORTUNITY,
]
);
if ($stage === null) {
$this->importStages(null, $stageId);
}
if ($stage === null) {
$this->logger->info('[HubSpot] Stage does not exist => ' . $stageId);
}
$this->cachedStages[$cacheKey] = $stage;
return $stage;
}
private function resolveAmount(array $properties): ?string
{
$amount = null;
if (! empty($properties['amount'])) {
$amount = str_replace(',', '', $properties['amount']);
}
if ($this->config->hasDefaultCurrencyFieldSet()) {
$valueFieldName = $this->config->getDefaultCurrencyField()->getCrmProviderId();
$amount = $properties[$valueFieldName] ?? $amount;
}
return $amount;
}
private function parseCleanDatetime(string $datetime): ?Carbon
{
// Treat pre-1980 values as invalid
$minValidDate = Carbon::parse('1980-01-01 00:00:00');
try {
$date = Carbon::parse($datetime);
if ($minValidDate->gt($date)) {
return null;
}
return $date;
} catch (Exception) {
return null; // On parse error, treat as null
}
}
private function resolveDealProbability(?string $stageProbability): int
{
if ($stageProbability === null) {
return 0;
}
$probability = (float) $stageProbability;
return $probability > 1 ? 0 : (int) ($probability * 100);
}
private function resolveForecastCategory(?string $forecastCategory): string
{
if (! $forecastCategory) {
return Forecast::FORECAST_CATEGORY_UNCATEGORIZED;
}
$forecastCategory = str_replace('_', ' ', $forecastCategory);
return ucwords(strtolower($forecastCategory));
}
private function importExternalFieldData(array $properties, int $opportunityId): void
{
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData($properties, $crmFields, $opportunityId);
}
private function importOpportunityContacts(Opportunity $opportunity, array $associations): void
{
// Handle empty or missing contact associations
if (empty($associations)) {
// Remove all existing contact associations if none provided
$this->removeAllOpportunityContacts($opportunity);
return;
}
// Use differential sync approach for better performance and accuracy
$this->syncOpportunityContactsDifferential($opportunity, $associations);
}
/**
* Sync opportunity contacts using differential approach
* This compares current vs new associations and only makes necessary changes
*/
private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void
{
$currentContactCrmIds = $this->getCurrentContactCrmIds($opportunity);
$contactAssociationIds = array_keys($contactAssociations);
$contactsToAdd = array_diff($contactAssociationIds, $currentContactCrmIds);
$contactsToRemove = array_diff($currentContactCrmIds, $contactAssociationIds);
if (empty($contactsToAdd) && empty($contactsToRemove)) {
return;
}
$this->logContactAssociationChanges($opportunity, $currentContactCrmIds, $contactAssociations, $contactsToAdd, $contactsToRemove);
$this->removeContactAssociations($opportunity, $contactsToRemove);
$this->addContactAssociations($opportunity, $contactsToAdd, $contactAssociations);
}
private function getCurrentContactCrmIds(Opportunity $opportunity): array
{
return $opportunity->contacts()
->pluck('contacts.crm_provider_id')
->toArray();
}
private function logContactAssociationChanges(
Opportunity $opportunity,
array $currentContactCrmIds,
array $contactAssociations,
array $contactsToAdd,
array $contactsToRemove
): void {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association changes', [
'opportunity_id' => $opportunity->getId(),
'current_contacts' => $currentContactCrmIds,
'new_contacts' => $contactAssociations,
'contacts_to_add' => $contactsToAdd,
'contacts_to_remove' => $contactsToRemove,
]);
}
private function removeContactAssociations(Opportunity $opportunity, array $contactsToRemove): void
{
if (empty($contactsToRemove)) {
return;
}
$contactsToDetach = $opportunity->contacts()
->whereIn('contacts.crm_provider_id', $contactsToRemove)
->pluck('contacts.id')
->toArray();
if (! empty($contactsToDetach)) {
$opportunity->contacts()->detach($contactsToDetach);
$this->logger->info('[' . $this->getDisplayName() . '] Removed contact associations', [
'opportunity_id' => $opportunity->getId(),
'removed_contact_crm_ids' => $contactsToRemove,
'removed_contact_count' => count($contactsToDetach),
]);
}
}
private function addContactAssociations(Opportunity $opportunity, array $contactsToAdd, array $contactAssociations): void
{
if (empty($contactsToAdd)) {
return;
}
$contactsAdded = [];
foreach ($contactsToAdd as $crmId) {
$id = $contactAssociations[$crmId];
if ($this->attachSingleContact($opportunity, (string) $crmId, $id)) {
$contactsAdded[] = $crmId;
}
}
$this->logAddedContacts($opportunity, $contactsAdded);
}
private function attachSingleContact(Opportunity $opportunity, string $crmId, int $id): bool
{
try {
$contact = $this->crmEntityRepository->findContactByConfigurationAndId($this->config, $id);
if (! $contact) {
return false;
}
return $this->performContactAttachment($opportunity, $contact, $crmId);
} catch (\Throwable $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Failed to add contact association', [
'opportunity_id' => $opportunity->getId(),
'contact_crm_id' => $crmId,
'error' => $e->getMessage(),
]);
return false;
}
}
private function performContactAttachment(Opportunity $opportunity, Contact $contact, string $crmId): bool
{
try {
$opportunity->contacts()->attach($contact->getId(), [
'crm_provider_id' => $crmId,
]);
return true;
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$this->logger->info('[' . $this->getDisplayName() . '] Contact association already exists', [
'contact_id' => $contact->getId(),
'contact_crm_id' => $crmId,
'opportunity_id' => $opportunity->getId(),
]);
return false;
}
throw $e;
}
}
private function logAddedContacts(Opportunity $opportunity, array $contactsAdded): void
{
if (! empty($contactsAdded)) {
$this->logger->info('[' . $this->getDisplayName() . '] Added contact associations', [
'opportunity_id' => $opportunity->getId(),
'contacts_to_add_count' => count($contactsAdded),
'added_contact_crm_ids' => $contactsAdded,
'added_contacts_count' => count($contactsAdded),
]);
}
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout
title="Account disconnected"
textPosition="center"
:icon="faUnlink"
:class="$style.layout"
>
<div :class="$style.container" v-if="providersLoaded">
<p>
<strong>
It looks like your {{ localProvider.displayName }} account has become
disconnected
</strong>
</p>
<p :class="$style.small">Please re-connect to continue</p>
<p v-if="isInIframe">
We'll open the {{ localProvider.displayName }} authentication in a new
tab. Please return here and refresh the page once complete
</p>
<GoogleLikeButton
v-if="localProvider.viaIntegrationApp && crmTokenLoaded"
as="a"
:key="localProvider.name"
:brand-logo="localProvider.name"
:class="$style.connectButton"
@click="integrationAppOnClick"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
<GoogleLikeButton
v-if="!localProvider.viaIntegrationApp"
as="a"
:key="localProvider.name"
:href="`/auth/redirect/${localProvider.name}`"
:target="target"
:brand-logo="localProvider.name"
:class="$style.connectButton"
>
Sign in with {{ localProvider.displayName }}
</GoogleLikeButton>
</div>
<BuildInfo />
<KioskBanner />
</WelcomeLayout>
</template>
<script>
import window from "window";
import axios from "axios";
import { faUnlink } from "@fortawesome/pro-regular-svg-icons";
import isInIframe from "@/utils/isInIframe";
import BuildInfo from "@/components/layout/BuildInfo/BuildInfo.vue";
import KioskBanner from "@/components/shared/KioskBanner/KioskBanner.vue";
import WelcomeLayout from "@/components/layout/WelcomeLayout/WelcomeLayout.vue";
import GoogleLikeButton from "@/components/shared/Buttons/GoogleLikeButton.vue";
import { showSnackbarError, normalizeError } from "@/utils/index";
import { IntegrationAppClient } from "@integration-app/sdk";
export default {
name: "ConnectPage",
components: {
BuildInfo,
KioskBanner,
WelcomeLayout,
GoogleLikeButton,
},
data() {
return {
...window.connectData,
crmToken: null,
faUnlink,
isInIframe,
providers: [],
providersLoaded: false,
crmTokenLoaded: false,
};
},
computed: {
localProvider() {
return this.providers.find((e) => e.name === this.provider);
},
target() {
return this.isInIframe ? "_blank" : null;
},
},
created() {
this.getProviders();
},
mounted() {
this.showErrors();
},
watch: {
providersLoaded() {
if (this.providersLoaded) {
this.prepareIntegrationAppConnection();
}
},
},
methods: {
showErrors() {
if (!this.error) return;
showSnackbarError(this.error, undefined, undefined, false);
},
unwrapEntityResponse({ data }) {
return data.map(({ icon, name, displayName, viaIntegrationApp }) => {
return { icon, name, displayName, viaIntegrationApp };
});
},
async getProviders() {
try {
const response = await axios.get("/api/v1/connect-providers");
this.providers = this.unwrapEntityResponse(response);
this.providersLoaded = true;
} catch {
showSnackbarError(
"An error occurred, while loading form data (connect providers).",
);
}
},
async prepareIntegrationAppConnection() {
if (this.localProvider.viaIntegrationApp) {
try {
const response = await axios.get("/api/v1/integration-app-token");
this.crmToken = response.data.token;
this.crmTokenLoaded = true;
} catch (error) {
console.log(error);
showSnackbarError(
`An error occurred while preparing the page.
Try refreshing, if the error persists get in touch with the Jiminny team.`,
);
}
}
},
async integrationAppOnClick() {
console.log('[IntegrationApp] integrationAppOnClick called');
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
}).catch((err) => {
console.log('[IntegrationApp] openNewConnection rejected:', err);
return null;
});
console.log('[IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));
// [IntegrationApp] openNewConnection resolved: {
// "id":"69e0b41a67d0068c2ca0b48e",
// "name":"Zoho CRM",
// "userId":"1ece66c8-feb1-4df1-b321-21607daf4623",
// "tenantId":"69e0b3faef3e7b6248189289",
// "isTest":false,
// "connected":true,
// "state":"READY",
// "errors":[],
// "integrationId":"66fe6c913202f3a165e3c14d",
// "externalAppId":"6671653e7e2d642e4e41b0fa",
// "authOptionKey":"",
// "createdAt":"2026-04-16T10:04:10.420Z",
// "updatedAt":"2026-04-16T10:04:10.575Z",
// "retryAttempts":0,
// "isDeactivated":false
// }
if (connection && connection.disconnected !== true && connection.connected !== false) {
console.log('[IntegrationApp] connection condition matched');
try {
const saveRequest = await axios.post(
"/api/v1/integration-app-connect",
);
if (saveRequest.data && saveRequest.data.success === true) {
/** If all is good refresh the page here */
window.location = "/dashboard";
return;
}
throw new Error(saveRequest.data.message);
} catch (error) {
console.log(error);
showSnackbarError(normalizeError(error));
}
}
},
},
};
</script>
<style module lang="less" src="./connect.less"></style>
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
46973
|