|
45182
|
NULL
|
0
|
2026-04-17T09:24:00.345279+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776417840345_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileProfilesWindowEditViewHistoryBookmarks→ FirefoxFileProfilesWindowEditViewHistoryBookmarks→ToolsHelpmeet.google.com/xpx-omah-rknllian Kyuchukov (Presenting, annotating)‹ >0 lhl¿ Support Daily • in 2h 37 m+NNYllian KyuchukovNikolay NikoloyVasil VasilevMihail Mihaylov"Lukas KovalikFo 17 kor 12.23SwSCloudWatchLop monooeen* Al Operations• GenAl Observability• Application Signals (APM)ktrascratare Monitoring* LogsLog ManaohLog Acalytios [Uve TalLogs inighes• Metrics• Netwock Monitoring(Option+s)©wocker-delayed/worker-delryed/BbMABM8ND40he968a52464e13e254Log eventYou can une the hiter bar beiow to search for and match terms, phrases, or walues inn your lon events. Leann more anulee deoewos desomMessage(2826-04-37 06:17:23) peodaction, INFO: (etersinecpenCX000181:20008800070018001000001-30086lemdoWrewit[c8xt-04-1700:17225) production.3M0: (ProceasAlAutonottorinelynis) Anolysis is to bo written in Cok(toeyч.0в3e1_1F (79449, "tange1d03eCt,type* (*opportumtty*, *remte.a0jeCt_peovtder.U°/^310297S8243*, *renote.field,grevtcer.l6*/@chargion__detatls*) (caereletion.16°1*340542) 6t)e-4301-|кахіести", tлеск,16:*2/30972e-0017-4a5/.bcdl-cofch(*nn,(8 12M24, "tenget, 16° (794)4), "terget, tyße" ( "oporteity", Чеsое,noryet,ho"1 вкотоодавв золнони, содескакуро сoрonnltу leycoonorgies seeoiotes emereororee aernortition deteils*:*Corgetition: X soGeee Tila(vidence(fevien af ell calls ond emitls)(2826-04-17 06:17:24) production. IN/0: OkeindexforoppdItykistener) Schedule reindexing job ['opportueity.i6° :734349) ("correlation,(6*: ^H49562%-4P%e -4381-«S74-осдостевстк", "вресе_16°1*2130972e-0еf7-405f-вc68-сbfceersbs")|(2826-04-17 06:17:25) production. IN/0: (DetermineUpdateOperationAction)wosrdos 18 Wrerentes Uoodtttwwwttwosktio.NotProcessiwtonsttoeenicnswisisto.bearittentmtem(Ttonget_dbject,1f":759434), "terpet_object,type*:*opportunity*."renote_object.provider,(4":"5802925424}*, "renote,field,provider_(d*: *Conpetition_detalla") ("correlatiso, 16°:"34954296-419e-43os-0G100% C8• Fri 17 Apr 12:23:596Daily - Processing16352| 4738|2026-03-17 09:21:10|2026-04-17 07:50:59|16378| 240|2026-04-16 12:42:37|2026-04-17 05:19:20|18281| 1911|2026-04-17 04:30:48|2026-04-17 07:50:59|20612| 2425|2026-04-17 04:31:13|2026-04-17 07:50:56|Mihail Mihaylov 11:44 AMMihail Mihaylov sent an imageSELECT os.stage_id,s.crm_provider_id, s.name,COUNT(*) as cntFROM opportunity_stages osJOIN stages s ON s.id =os.stage_idWHERE os.opportunity_id =7594349GROUP BY os.stage_id,s.crm_provider _id, s.nameORDER BY cnt DESC:Send a message© Cиен12:23 PM | Daily - Processing...
|
NULL
|
5534840259384766938
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileProfilesWindowEditViewHistoryBookmarks→ FirefoxFileProfilesWindowEditViewHistoryBookmarks→ToolsHelpmeet.google.com/xpx-omah-rknllian Kyuchukov (Presenting, annotating)‹ >0 lhl¿ Support Daily • in 2h 37 m+NNYllian KyuchukovNikolay NikoloyVasil VasilevMihail Mihaylov"Lukas KovalikFo 17 kor 12.23SwSCloudWatchLop monooeen* Al Operations• GenAl Observability• Application Signals (APM)ktrascratare Monitoring* LogsLog ManaohLog Acalytios [Uve TalLogs inighes• Metrics• Netwock Monitoring(Option+s)©wocker-delayed/worker-delryed/BbMABM8ND40he968a52464e13e254Log eventYou can une the hiter bar beiow to search for and match terms, phrases, or walues inn your lon events. Leann more anulee deoewos desomMessage(2826-04-37 06:17:23) peodaction, INFO: (etersinecpenCX000181:20008800070018001000001-30086lemdoWrewit[c8xt-04-1700:17225) production.3M0: (ProceasAlAutonottorinelynis) Anolysis is to bo written in Cok(toeyч.0в3e1_1F (79449, "tange1d03eCt,type* (*opportumtty*, *remte.a0jeCt_peovtder.U°/^310297S8243*, *renote.field,grevtcer.l6*/@chargion__detatls*) (caereletion.16°1*340542) 6t)e-4301-|кахіести", tлеск,16:*2/30972e-0017-4a5/.bcdl-cofch(*nn,(8 12M24, "tenget, 16° (794)4), "terget, tyße" ( "oporteity", Чеsое,noryet,ho"1 вкотоодавв золнони, содескакуро сoрonnltу leycoonorgies seeoiotes emereororee aernortition deteils*:*Corgetition: X soGeee Tila(vidence(fevien af ell calls ond emitls)(2826-04-17 06:17:24) production. IN/0: OkeindexforoppdItykistener) Schedule reindexing job ['opportueity.i6° :734349) ("correlation,(6*: ^H49562%-4P%e -4381-«S74-осдостевстк", "вресе_16°1*2130972e-0еf7-405f-вc68-сbfceersbs")|(2826-04-17 06:17:25) production. IN/0: (DetermineUpdateOperationAction)wosrdos 18 Wrerentes Uoodtttwwwttwosktio.NotProcessiwtonsttoeenicnswisisto.bearittentmtem(Ttonget_dbject,1f":759434), "terpet_object,type*:*opportunity*."renote_object.provider,(4":"5802925424}*, "renote,field,provider_(d*: *Conpetition_detalla") ("correlatiso, 16°:"34954296-419e-43os-0G100% C8• Fri 17 Apr 12:23:596Daily - Processing16352| 4738|2026-03-17 09:21:10|2026-04-17 07:50:59|16378| 240|2026-04-16 12:42:37|2026-04-17 05:19:20|18281| 1911|2026-04-17 04:30:48|2026-04-17 07:50:59|20612| 2425|2026-04-17 04:31:13|2026-04-17 07:50:56|Mihail Mihaylov 11:44 AMMihail Mihaylov sent an imageSELECT os.stage_id,s.crm_provider_id, s.name,COUNT(*) as cntFROM opportunity_stages osJOIN stages s ON s.id =os.stage_idWHERE os.opportunity_id =7594349GROUP BY os.stage_id,s.crm_provider _id, s.nameORDER BY cnt DESC:Send a message© Cиен12:23 PM | Daily - Processing...
|
45180
|
|
45184
|
NULL
|
0
|
2026-04-17T09:24:03.365169+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776417843365_m2.jpg...
|
PhpStorm
|
faVsco.js – console [EU]
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFV faVsco.js vProjectv= ids.txt=infection. PhpStormFV faVsco.js vProjectv= ids.txt=infection.son.distM+ INSTALL.mdM+ INTERNAL_WEBHOOK SETUPE jiminny_storageM+ licenses.mdM MakefileO package-lock.json= phpstan.neon.dist= phpstan-baseline.neor< phpunit.xmlTe raw_sql_query.sqlM+ KEADME.mO{o sonar-project.propertiesE test.py<> Untitled Diagram.xmlIs vetur.config.jsM+ WEBHOOK_FILTERING_IMPLE› ih External Librariesv E* Scratches and Consolesv D Database ConsolesvA-Uc consoe -u4 DEAL RISKS [EU]A DI [EU]A EU [EU]v A jiminny@localhostA console [jiminny@localtDI [jiminny@localhost]A HS_local [iminny@localA SF [jiminny@localhost]A zoho_dev ljiminny@loceV & PROD& consoe PRODIA console_1 [PROD]A DI [PROD]> LQAA QAI2 QAI PROD4 STAGINGA console [STAGING]L console_1 [STAGING]« uranus [STAGING]> D Extensionsv D Scratches= phostorm_shortcuts.txtE scratch.txtC° scratch_1.json(° scratch_2.jsonC* scratch_3.json= scratch_4.txtong scratch_5.phpphỹ scratch_6.php(° scratch_7.jsonC° stage2.jsonE° test<° test.htmlptS tmp.phpViewNavigatelaraveRefactor#1894 on.lY-18909-automated-renorts-ask-liminnv• AutomatedReportsService.php© SendReportJob.php© SendReportMailJob.php© ReportController.phpTokenBuilder.phpc leamsetuocontroller.onopnp apl.onoFilesystem.php• Team.php© TrackProviderInstalledEvent.php© RequestGenerateReportJob.php© OpportunitySyncTrait.phpc CrealenelaAcuiviyevent.onoOpportunity.php€ InteractsWithPivotTable.php© OpportunityStageUpdated.phpC OpportunityPendingAiAnalysisAfterStageChanged.phpC RunOpportunityAiAnalysis.phpProcessaiAulomalionAnalysiskesults.ono© ImportOpportunityBatch.phpImportBatchJobTrait.php© Service.php$stageCo W .*13/29PLT :trait OpportunitySyncTraitHSVEVIAVprivate function update0pportunity(string $crmId,array $properties,array $associations): Opportuni$data = $this->build0pportunityData($properties, $accountId, $businessProcess,E custom.logV connect.vue= laravel.logA SF [jiminny@localhost]C* scratch_1.jsonV Onboard.vueHs local liminnyalocalnostiL console [EU] X© CrmEntityRepository.phpfi crm_configurations [EU]A console [PROD]X:Auto vPlaygroundt.owner_id FROM social_accounts sa157115721573JOIN users u on u.id = sa.sociable_idJOIN teams t1.n<->1: on t.id = u.team_idWHERE u. team_id = 400 and sa.provider = 'hubspat';804806807808809810811812813814816817818819820838839840841842843845846847848849850851852853854855856857858859860861862$attributes = ['crm_configuration_id' $this->config->getId(),'crm_provider_id' => $crmId,E15791:-1583$values = array_merge(Sattributes, $data);$opportunity = $this->crmEntityRepository->upsert0pportunity($attributes, $values);=1584-1586фchis->importexcerna triecabatalepropercles, фopporcuntty-sgectaoe$this->update0pportunityAssociations(Sopportunity, $associations);158715881589return $opportunity;159015911592Lusages1593private function resolveAccountId(array $associations): Pintf...}=159415952 usages1596private function buildOpportunityData(array $properties,1597nint naccouncro1599?BusinessProcess SbusinessProcess.1600?Stage=1601): array {$ownerId = null;$profile = null;1603if (! empty($properties['hubspot_owner_id'])) {$ownerId = $properties['hubspot_owner_id'];-1606Sprofile = $this->cNmEnt¿tyReposіtory->fjndProf1LeByExternaLId(Sthis->config, (string) $owner* 26071608Sname = "Unknown"1610if (isset($propertiesl'dealname'])) {—1611$name = mb_strimwidth($properties['dealname'],start:0,width: 128);1613-1614$amount = $this->resolveAmount($properties);1615$currency = $properties[ 'deal_currency_code'] ?? null;E1616$closeDate = null;:1618if (! empty($propertiesl'elosedate'])) {_1619$closebate = cardon::parse(sproperclest closedateJ)->tormaut format: "Y-m-d))$remotelyCreatedAt = null;if (! empty(Sproperties['createdate']) && strtotime(Spropertiesl' createdate'])) €ttings. // Modify Shortcuts // Don't Show Again (a minute ago)A console [STAGING]S"jiminny026 49 A22 X3 X104 A Vselect x trol tearuresaselect * from team_features where feature_id = 40;select * from teams where id = 556; # owner: 18101, crm: 477select * from crm_configurations where id = 477;SELECT * FROM users WHERE id = 18101;SELECTICONCAT(u.id, LASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE "' END)u.email,sa.*,t.owner id FROM social accounts saJOIN users u on u.id = sa.sociable idJOIN teams t1..n<->1: on t.id = u.team idWHERE U. team_id = 556 and sa.provider = 'integration-app';select * from opportunities where id = 7594349;select * from opportunity_stages where opportunity_id = 7594349;select * from business_processes where id = 6024;select * frombusiness_process_stages where stage_id = 16352;select * from business_process_stages where business_process_id = 6024;select * from stages where team_id = 459;select * from teams where id = 459;SELECTCONCAT(u.id, CASE WHEN U.id = t.owner_id THEN ' (owner)' ELSE "' END)u.email,sa.*,t.owner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teams t1..n<->1: on t.id = u.team_idWHERE U.team_id = 459 and sa.provider = 'hubspot':SELECT os.stage_id, s.crm_provider_id, s.name, COUNT(*) as cntFROM opportunity_stages osJOIN stages s1..n<->1: ON s.id = os.stage_idWHERE os.opportunity_id = 7594349GROUP BY os.stage_id, s.crm_provider_id, s.nameORDER BY cnt DESO;s. cri_provider_id, s.name, fs. team_id, s.crm_configuration_icFROM stages sJOIN business_process_stages bps1<->1..n: ON bps.stage_id = s.idWHERE bps.business_process_id = 6024AND s.crm_provider_id ='contractsent' :select * from stages where id IN (16352,20612,18281, 7344,16378, 16309, 50362> 0Support Daily - in 2 h 36 m100% C28 • Fri 17 Apr 12:24:02CascadeAutomated Report RetrDebugging Opportunit+D..Letme check exacuy now derripelinestacebyconaltions euerles.Exo orec Crmcnulvreotorv ono ano searcheo ce riellnestacebvconeltionsNow I have the complete picture.This Is Exactly The Cause Of The OscillationgetPipelineStageByConditions does ->where(['crm_provider_id' => 'contractsent', 'type' » 'opporLundly do-ILrsl.with 12 rows all having erm provider 1d = "contractsent", →tirsto returns a non-dererministieresult - whichever row MySQL decides to return first, which can vary between:different query executionsomerent index scansafter imoortstaoeso runs ano creates/vocates rows chanaino insertion order)So each call to resolveStage() for this deal can return a different one of those 12 stage IDs. Theoooonuniy's stare 1. cers Writen Witn Wnarever →tirst reuins — wnich is wnv vou see he 10. 4high-count stages (16352, 20612, 18281, 7344) that each have thousands of hits and the rarer ones onlynave l-<Why there are 12 stages with crm_provider_id = "contractsent"These come from 12 different business processes (different pipelines) across other CRM configurationsTnar were mooned over time, Wnen 1moortstadeso runs,coes updateorcreate kevec on omn provider_id scoped to $this->config - but the businessProcess->stages () relationship in getPipelineStageByConditions traverses the pivot table business_process_stages, and those 12 stages all belong to businessprocess 6024 va that pivotRun this to confirm:• sqlSELECT s.id, s.crm_provider_id, s.name, s.team_id, s.crm_configuration_idFROM stages sJOIN business_process_stages bps ON bps.stage_id = s.idWHERE bps.business_process_id = 6024AND s.crm provider id = 'contractsent':If all 12 have team_id = 459, then imoortStages( with businessProcess→>staqes()→>sync($stages) isattaching stages from other pipelines to business process 6024 each time it runs — because sync()replaces the entire pivot with whatever stage IDs were just upserted.Cascade & XIThe FixresolveStage() needs to scope the lookup to the specific business process only - which it already doesvia sousinessprocess->stades. but anporcsraces s corruotine tne oivor oy syncine all stages acrossall hinalinac inta te same ousiness orocess,Command 9el ata fix:• sạl- Check which of the 12 stages actually belongs to pipeline "default" (bp 6024)SELECT S.id, S.crm_provider_id, s.name, s.crm_contiguration_idFROM stages sJOIN business process stages bps ON bps.stage id = s.idWhckE bos.business process 1d = 6024OKDekbys.crn provlder 1o. S.lohThis will show you all stages attached to bp 6024 - and you'll almost certainly see stages from completelydifterent oioelines/CRM contias attached there because sunco In importstageso is re-writina the oivoton every run.Just one record ["id": 16352"crm_provider_id": "contractsent",: "Commercials","team_id": 459"crm_configuration_id": 367Shortcuts conflictsCone caler Aoove ane imo e snorcr contlctwith macOS shortcuts. Modify these shortcuts or..Modltv shortcutsDon't show Again<> CodeClaude Sonnet 4.6...
|
NULL
|
7727310981134914522
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFV faVsco.js vProjectv= ids.txt=infection. PhpStormFV faVsco.js vProjectv= ids.txt=infection.son.distM+ INSTALL.mdM+ INTERNAL_WEBHOOK SETUPE jiminny_storageM+ licenses.mdM MakefileO package-lock.json= phpstan.neon.dist= phpstan-baseline.neor< phpunit.xmlTe raw_sql_query.sqlM+ KEADME.mO{o sonar-project.propertiesE test.py<> Untitled Diagram.xmlIs vetur.config.jsM+ WEBHOOK_FILTERING_IMPLE› ih External Librariesv E* Scratches and Consolesv D Database ConsolesvA-Uc consoe -u4 DEAL RISKS [EU]A DI [EU]A EU [EU]v A jiminny@localhostA console [jiminny@localtDI [jiminny@localhost]A HS_local [iminny@localA SF [jiminny@localhost]A zoho_dev ljiminny@loceV & PROD& consoe PRODIA console_1 [PROD]A DI [PROD]> LQAA QAI2 QAI PROD4 STAGINGA console [STAGING]L console_1 [STAGING]« uranus [STAGING]> D Extensionsv D Scratches= phostorm_shortcuts.txtE scratch.txtC° scratch_1.json(° scratch_2.jsonC* scratch_3.json= scratch_4.txtong scratch_5.phpphỹ scratch_6.php(° scratch_7.jsonC° stage2.jsonE° test<° test.htmlptS tmp.phpViewNavigatelaraveRefactor#1894 on.lY-18909-automated-renorts-ask-liminnv• AutomatedReportsService.php© SendReportJob.php© SendReportMailJob.php© ReportController.phpTokenBuilder.phpc leamsetuocontroller.onopnp apl.onoFilesystem.php• Team.php© TrackProviderInstalledEvent.php© RequestGenerateReportJob.php© OpportunitySyncTrait.phpc CrealenelaAcuiviyevent.onoOpportunity.php€ InteractsWithPivotTable.php© OpportunityStageUpdated.phpC OpportunityPendingAiAnalysisAfterStageChanged.phpC RunOpportunityAiAnalysis.phpProcessaiAulomalionAnalysiskesults.ono© ImportOpportunityBatch.phpImportBatchJobTrait.php© Service.php$stageCo W .*13/29PLT :trait OpportunitySyncTraitHSVEVIAVprivate function update0pportunity(string $crmId,array $properties,array $associations): Opportuni$data = $this->build0pportunityData($properties, $accountId, $businessProcess,E custom.logV connect.vue= laravel.logA SF [jiminny@localhost]C* scratch_1.jsonV Onboard.vueHs local liminnyalocalnostiL console [EU] X© CrmEntityRepository.phpfi crm_configurations [EU]A console [PROD]X:Auto vPlaygroundt.owner_id FROM social_accounts sa157115721573JOIN users u on u.id = sa.sociable_idJOIN teams t1.n<->1: on t.id = u.team_idWHERE u. team_id = 400 and sa.provider = 'hubspat';804806807808809810811812813814816817818819820838839840841842843845846847848849850851852853854855856857858859860861862$attributes = ['crm_configuration_id' $this->config->getId(),'crm_provider_id' => $crmId,E15791:-1583$values = array_merge(Sattributes, $data);$opportunity = $this->crmEntityRepository->upsert0pportunity($attributes, $values);=1584-1586фchis->importexcerna triecabatalepropercles, фopporcuntty-sgectaoe$this->update0pportunityAssociations(Sopportunity, $associations);158715881589return $opportunity;159015911592Lusages1593private function resolveAccountId(array $associations): Pintf...}=159415952 usages1596private function buildOpportunityData(array $properties,1597nint naccouncro1599?BusinessProcess SbusinessProcess.1600?Stage=1601): array {$ownerId = null;$profile = null;1603if (! empty($properties['hubspot_owner_id'])) {$ownerId = $properties['hubspot_owner_id'];-1606Sprofile = $this->cNmEnt¿tyReposіtory->fjndProf1LeByExternaLId(Sthis->config, (string) $owner* 26071608Sname = "Unknown"1610if (isset($propertiesl'dealname'])) {—1611$name = mb_strimwidth($properties['dealname'],start:0,width: 128);1613-1614$amount = $this->resolveAmount($properties);1615$currency = $properties[ 'deal_currency_code'] ?? null;E1616$closeDate = null;:1618if (! empty($propertiesl'elosedate'])) {_1619$closebate = cardon::parse(sproperclest closedateJ)->tormaut format: "Y-m-d))$remotelyCreatedAt = null;if (! empty(Sproperties['createdate']) && strtotime(Spropertiesl' createdate'])) €ttings. // Modify Shortcuts // Don't Show Again (a minute ago)A console [STAGING]S"jiminny026 49 A22 X3 X104 A Vselect x trol tearuresaselect * from team_features where feature_id = 40;select * from teams where id = 556; # owner: 18101, crm: 477select * from crm_configurations where id = 477;SELECT * FROM users WHERE id = 18101;SELECTICONCAT(u.id, LASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE "' END)u.email,sa.*,t.owner id FROM social accounts saJOIN users u on u.id = sa.sociable idJOIN teams t1..n<->1: on t.id = u.team idWHERE U. team_id = 556 and sa.provider = 'integration-app';select * from opportunities where id = 7594349;select * from opportunity_stages where opportunity_id = 7594349;select * from business_processes where id = 6024;select * frombusiness_process_stages where stage_id = 16352;select * from business_process_stages where business_process_id = 6024;select * from stages where team_id = 459;select * from teams where id = 459;SELECTCONCAT(u.id, CASE WHEN U.id = t.owner_id THEN ' (owner)' ELSE "' END)u.email,sa.*,t.owner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teams t1..n<->1: on t.id = u.team_idWHERE U.team_id = 459 and sa.provider = 'hubspot':SELECT os.stage_id, s.crm_provider_id, s.name, COUNT(*) as cntFROM opportunity_stages osJOIN stages s1..n<->1: ON s.id = os.stage_idWHERE os.opportunity_id = 7594349GROUP BY os.stage_id, s.crm_provider_id, s.nameORDER BY cnt DESO;s. cri_provider_id, s.name, fs. team_id, s.crm_configuration_icFROM stages sJOIN business_process_stages bps1<->1..n: ON bps.stage_id = s.idWHERE bps.business_process_id = 6024AND s.crm_provider_id ='contractsent' :select * from stages where id IN (16352,20612,18281, 7344,16378, 16309, 50362> 0Support Daily - in 2 h 36 m100% C28 • Fri 17 Apr 12:24:02CascadeAutomated Report RetrDebugging Opportunit+D..Letme check exacuy now derripelinestacebyconaltions euerles.Exo orec Crmcnulvreotorv ono ano searcheo ce riellnestacebvconeltionsNow I have the complete picture.This Is Exactly The Cause Of The OscillationgetPipelineStageByConditions does ->where(['crm_provider_id' => 'contractsent', 'type' » 'opporLundly do-ILrsl.with 12 rows all having erm provider 1d = "contractsent", →tirsto returns a non-dererministieresult - whichever row MySQL decides to return first, which can vary between:different query executionsomerent index scansafter imoortstaoeso runs ano creates/vocates rows chanaino insertion order)So each call to resolveStage() for this deal can return a different one of those 12 stage IDs. Theoooonuniy's stare 1. cers Writen Witn Wnarever →tirst reuins — wnich is wnv vou see he 10. 4high-count stages (16352, 20612, 18281, 7344) that each have thousands of hits and the rarer ones onlynave l-<Why there are 12 stages with crm_provider_id = "contractsent"These come from 12 different business processes (different pipelines) across other CRM configurationsTnar were mooned over time, Wnen 1moortstadeso runs,coes updateorcreate kevec on omn provider_id scoped to $this->config - but the businessProcess->stages () relationship in getPipelineStageByConditions traverses the pivot table business_process_stages, and those 12 stages all belong to businessprocess 6024 va that pivotRun this to confirm:• sqlSELECT s.id, s.crm_provider_id, s.name, s.team_id, s.crm_configuration_idFROM stages sJOIN business_process_stages bps ON bps.stage_id = s.idWHERE bps.business_process_id = 6024AND s.crm provider id = 'contractsent':If all 12 have team_id = 459, then imoortStages( with businessProcess→>staqes()→>sync($stages) isattaching stages from other pipelines to business process 6024 each time it runs — because sync()replaces the entire pivot with whatever stage IDs were just upserted.Cascade & XIThe FixresolveStage() needs to scope the lookup to the specific business process only - which it already doesvia sousinessprocess->stades. but anporcsraces s corruotine tne oivor oy syncine all stages acrossall hinalinac inta te same ousiness orocess,Command 9el ata fix:• sạl- Check which of the 12 stages actually belongs to pipeline "default" (bp 6024)SELECT S.id, S.crm_provider_id, s.name, s.crm_contiguration_idFROM stages sJOIN business process stages bps ON bps.stage id = s.idWhckE bos.business process 1d = 6024OKDekbys.crn provlder 1o. S.lohThis will show you all stages attached to bp 6024 - and you'll almost certainly see stages from completelydifterent oioelines/CRM contias attached there because sunco In importstageso is re-writina the oivoton every run.Just one record ["id": 16352"crm_provider_id": "contractsent",: "Commercials","team_id": 459"crm_configuration_id": 367Shortcuts conflictsCone caler Aoove ane imo e snorcr contlctwith macOS shortcuts. Modify these shortcuts or..Modltv shortcutsDon't show Again<> CodeClaude Sonnet 4.6...
|
45181
|
|
45274
|
NULL
|
0
|
2026-04-17T09:29:13.316078+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776418153316_m2.jpg...
|
PhpStorm
|
faVsco.js – console [EU]
|
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
Show Replace Field
Search History
$stage
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
13/29
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
32
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),
]);
}
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Code changed:
Hide
Sync Changes
Hide This Notification
26
9
22
3
104...
|
[{"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.14960937,"height":0.022222223},"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.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.17777778,"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.17708333,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"$stage","depth":4,"bounds":{"left":0.1515625,"top":0.17708333,"width":0.0515625,"height":0.013888889},"value":"$stage","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.17708333,"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.17708333,"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.17708333,"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.17708333,"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":"13/29","depth":4,"bounds":{"left":0.26171875,"top":0.17638889,"width":0.030078124,"height":0.015277778},"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.29179686,"top":0.17569445,"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.17569445,"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.17569445,"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.17569445,"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.44960937,"top":0.17569445,"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":"32","depth":4,"bounds":{"left":0.40859374,"top":0.20277777,"width":0.012109375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.4230469,"top":0.20277777,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.43476564,"top":0.20277777,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.44804686,"top":0.2013889,"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.45664063,"top":0.2013889,"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":"Execute","depth":4,"bounds":{"left":0.46679688,"top":0.10763889,"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.47695312,"top":0.10763889,"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.48984376,"top":0.10763889,"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.5,"top":0.10763889,"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.5101563,"top":0.10763889,"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.52304685,"top":0.10763889,"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.5359375,"top":0.10763889,"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.5671875,"top":0.10763889,"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.5800781,"top":0.10763889,"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.6917969,"top":0.10763889,"width":0.033203125,"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":"26","depth":4,"bounds":{"left":0.6417969,"top":0.12916666,"width":0.012109375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.65625,"top":0.12916666,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"22","depth":4,"bounds":{"left":0.66796875,"top":0.12916666,"width":0.01171875,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.6820313,"top":0.12916666,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"104","depth":4,"bounds":{"left":0.69375,"top":0.12916666,"width":0.0140625,"height":0.013194445},"role_description":"text"}]...
|
7109464136010812912
|
-8178087411261796058
|
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
Show Replace Field
Search History
$stage
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
13/29
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
32
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),
]);
}
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Code changed:
Hide
Sync Changes
Hide This Notification
26
9
22
3
104...
|
45265
|
|
45275
|
NULL
|
0
|
2026-04-17T09:29:17.109740+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776418157109_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEdit→ViewHistoryBookmarksProfilesQ.Tool FirefoxFileEdit→ViewHistoryBookmarksProfilesQ.ToolsWindowHelpmeet.google.com/xpx-omah-rknIlian Kyuchukov (Presenting, annotating)laln]Support Daily - in 2h 31 m100% C478 • Fri 17 Apr 12:29:17+18WindowHeip© UpdateCrmTargetDto© RunActivityAlAnalysisListener© OpportunityStageUpdated© OpportunityNew Cascade |© JiminnyDebugCommandcKemeFind in Flles 29 matches in fiiesMresionygAnalysis//OpportunityPendingAlAnalysisAfterStagComponent|AlAutomation|Listeners|Pending/Analysis/OpportunityPendingAiAnalysishanged: class,php-cs-fixer,cache -/jminnylapp11 Ica89Sd6ecd*, *Vopt\/PHP CS Fixentenp_folder5231\/app\/ConponentV/AiAutonation\/Listeners\/PendingAnalysis\/OpportunstyPSubmitAudiofileServiceTestvl• Q 8• Fri17 Apr 12:29.+ 0.sauea1 21<?phpdeclare(strict.t6 14vasten22 22024use AiAnalysuse Loghessaluse Collectt1 usageprotected colpublic strinTueg overprivateprivateprivate• Open results in new tab8 Vasil Vasliev +2Sopportunity = Sthis-›opportunityRepository->find(Sevent->getOpportunityId):|1f (1 Sopportunity) {OpportunityPendingAlAnalysisAfterStageChangedPS$I17• File maskcllian KyuchukovJIMINAINikolay NikolovVasil VasilevMihail MihaylovCmd +EnterUpon in rind mindowAsk anything (XOL)W Windsurf Teams (Pre-Release)22:51 (45 chars)UTF-8Co 4 spacesAamaster O VVISUAL2024-11_6.56.pngLukas Kovalik12:29 PM | Daily - Processing...
|
NULL
|
-2549870436079491621
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEdit→ViewHistoryBookmarksProfilesQ.Tool FirefoxFileEdit→ViewHistoryBookmarksProfilesQ.ToolsWindowHelpmeet.google.com/xpx-omah-rknIlian Kyuchukov (Presenting, annotating)laln]Support Daily - in 2h 31 m100% C478 • Fri 17 Apr 12:29:17+18WindowHeip© UpdateCrmTargetDto© RunActivityAlAnalysisListener© OpportunityStageUpdated© OpportunityNew Cascade |© JiminnyDebugCommandcKemeFind in Flles 29 matches in fiiesMresionygAnalysis//OpportunityPendingAlAnalysisAfterStagComponent|AlAutomation|Listeners|Pending/Analysis/OpportunityPendingAiAnalysishanged: class,php-cs-fixer,cache -/jminnylapp11 Ica89Sd6ecd*, *Vopt\/PHP CS Fixentenp_folder5231\/app\/ConponentV/AiAutonation\/Listeners\/PendingAnalysis\/OpportunstyPSubmitAudiofileServiceTestvl• Q 8• Fri17 Apr 12:29.+ 0.sauea1 21<?phpdeclare(strict.t6 14vasten22 22024use AiAnalysuse Loghessaluse Collectt1 usageprotected colpublic strinTueg overprivateprivateprivate• Open results in new tab8 Vasil Vasliev +2Sopportunity = Sthis-›opportunityRepository->find(Sevent->getOpportunityId):|1f (1 Sopportunity) {OpportunityPendingAlAnalysisAfterStageChangedPS$I17• File maskcllian KyuchukovJIMINAINikolay NikolovVasil VasilevMihail MihaylovCmd +EnterUpon in rind mindowAsk anything (XOL)W Windsurf Teams (Pre-Release)22:51 (45 chars)UTF-8Co 4 spacesAamaster O VVISUAL2024-11_6.56.pngLukas Kovalik12:29 PM | Daily - Processing...
|
45273
|
|
45345
|
NULL
|
0
|
2026-04-17T09:34:04.065730+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776418444065_m2.jpg...
|
PhpStorm
|
faVsco.js – console [EU]
|
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
Show Replace Field
Search History
$stage
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
12/29
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
32
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),
]);
}
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Code changed:
Hide
Sync Changes
Hide This Notification
26
9
22
3
104
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM team_features where team_id = 1;
SELECT * FROM teams WHERE name LIKE '%Vixio%'; # 340,270,11922
SELECT * FROM users WHERE team_id = 340; # 12015
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 340
and sa.provider = 'salesforce';
# and sa.provider = 'salesloft';
select * from crm_fields where crm_configuration_id = 270 and object_type = 'event';
# 125558 - Event Type - Event_Type__c
# 125552 - Event Status - Event_Status__c
SELECT * FROM sidekick_settings WHERE team_id = 340;
SELECT * FROM crm_field_values WHERE crm_field_id in (125552);
select * from activities where crm_configuration_id = 270
and type = 'conference' and crm_provider_id IS NOT NULL
and actual_start_time > '2024-09-16 09:00:00' order by scheduled_start_time;
SELECT * FROM activities WHERE id = 20871677;
SELECT * FROM crm_field_data WHERE activity_id = 20871677;
select * from crm_layouts where crm_configuration_id = 270;
select * from crm_layout_entities where crm_layout_id in (886,887);
SELECT * FROM crm_configurations WHERE id = 270;
select * from playbooks where team_id = 340; # 1514
select * from groups where team_id = 340;
SELECT * FROM crm_fields WHERE id IN (125393, 125401);
select g.name as 'team name', p.name as 'playbook name', f.label as 'activity type field' from groups g
join playbooks p on g.playbook_id = p.id
join crm_fields f on p.activity_field_id = f.id
where g.team_id = 340;
SELECT * FROM activities WHERE uuid_to_bin('0c180357-67d2-419e-a8c3-b832a3490770') = uuid; # 20448716
select * from crm_field_data where object_id = 20448716;
select * from activities where crm_configuration_id = 270 and provider = 'salesloft' order by id desc;
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%CybSafe%'; # 343,273,12008
select * from opportunities where team_id = 343;
select * from opportunities where team_id = 343 and crm_provider_id = '18099102526';
select * from opportunities where team_id = 343 and account_id = 945217482;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 343
and sa.provider = 'hubspot';
select * from accounts where team_id = 343 order by name asc;
select * from stages where crm_configuration_id = 273 and type = 'opportunity';
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%Voyado%'; # 353,283,12143
SELECT * FROM activities WHERE crm_configuration_id = 283 and account_id = 3777844 order by id desc;
SELECT * FROM accounts WHERE team_id = 353 AND name LIKE '%Salesloft%';
SELECT * FROM activities WHERE id = 20717903;
select * from participants where activity_id IN (20929172,20928605,20928468,20926272,20926271,20926270,20926269,20916499,20916454,20916436,20916435,20900015,20900014,20900013,20897312,20897243,20897241,20897237,20897232,20897229,20893648,20893231,20893230,20893229,20893228,20889784,20885039,20885038,20885037,20885036,20885035,20882728,20882708,20882703,20882702,20869828,20869811,20869806,20869801,20869799,20869798,20869796,20869795,20869794,20869761,20869760,20869759,20868688,20868687,20850340,20847195,20841710,20833967,20827021,20825307,20825305,20825297,20824615,20824400,20823927,20821760,20795588,20794233,20794057,20793710,20785811,20781789,20781394,20781307,20762651,20758453,20758282,20757323,20756643,20756636,20756629,20756627,20756606,20756605,20756604,20756603,20756602,20756600,20756599,20756598,20756595,20756594,20756589,20756587,20756577,20756573,20748918,20748386,20748385,20748384,20748383,20748382,20748381,20748380,20748379,20748377,20748375,20748373,20743301,20717905,20717904,20717903,20717901,20717899);
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 353
and sa.provider = 'salesforce';
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%modern world business solutions%'; # 345,275,12016, [EMAIL]
SELECT * FROM activities WHERE uuid_to_bin('3921d399-3fef-4609-a291-b0097a166d43') = uuid;
# id: 20940638, user: 12022, contact: 5305871
SELECT * FROM activity_summary_logs WHERE activity_id = 20940638;
select * from contacts where team_id = 345 and crm_provider_id = '30891432415' order by name asc; # 5305871
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 345
and sa.provider = 'hubspot';
select * from users where team_id = 345 and id = 12022;
SELECT * FROM crm_profiles WHERE user_id = 12022;
SELECT * FROM participants WHERE activity_id = 20940638;
SELECT * FROM users u
JOIN crm_profiles cp ON u.id = cp.user_id
WHERE u.team_id = 345;
select * from contacts where team_id = 345 and crm_provider_id = '30880813535' order by name desc; # 5305871
select * from team_features where team_id = 345;
SELECT * FROM activities WHERE uuid_to_bin('11701e2d-2f82-4dab-a616-1db4fad238df') = uuid; # 21115197
SELECT * FROM participants WHERE activity_id = 20897406;
SELECT * FROM activities WHERE uuid_to_bin('63ba55cd-1abc-447d-83da-0137000005b7') = uuid; # 20953912
SELECT * FROM activities WHERE crm_configuration_id = 275 and provider = 'ringcentral' and title like '%1252629100%';
SELECT * FROM activities WHERE id = 20946641;
SELECT * FROM crm_profiles WHERE user_id = 10211;
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%Lunio%'; # 120,97,10984, [EMAIL]
SELECT * FROM opportunities WHERE crm_configuration_id = 97 and crm_provider_id = '006N1000006c5PpIAI';
select * from stages where crm_configuration_id = 97 and type = 'opportunity';
select * from opportunities where team_id = 120;
select * from crm_configurations crm join teams t on crm.id = t.crm_id
where 1=1
AND t.current_billing_plan IS NOT NULL
AND crm.auto_sync_activity = 0
and crm.provider = 'hubspot';
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%Exclaimer%'; # 270,205,10053,[EMAIL]
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 270
and sa.provider = 'salesforce';
SELECT * FROM activities WHERE uuid_to_bin('b54df794-2a9a-4957-8d80-09a600ead5f8') = uuid; # 21637956
SELECT * FROM crm_profiles WHERE user_id = 11446;
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%Cygnetise%'; # 372,300,12554, [EMAIL]
select * from playbooks where team_id = 372;
select * from crm_fields where crm_configuration_id = 300 and object_type = 'event'; # 141340
SELECT * FROM crm_field_values WHERE crm_field_id = 141340;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 372
and sa.provider = 'salesforce';
select * ...
|
[{"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.14960937,"height":0.022222223},"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.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.17777778,"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.17708333,"width":0.00859375,"height":0.015277778},"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"$stage","depth":4,"bounds":{"left":0.1515625,"top":0.17708333,"width":0.0515625,"height":0.013888889},"value":"$stage","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.17708333,"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.17708333,"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.17708333,"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.17708333,"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":"12/29","depth":4,"bounds":{"left":0.26171875,"top":0.17638889,"width":0.030078124,"height":0.015277778},"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.29179686,"top":0.17569445,"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.17569445,"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.17569445,"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.17569445,"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.44960937,"top":0.17569445,"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":"32","depth":4,"bounds":{"left":0.40859374,"top":0.20277777,"width":0.012109375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.4230469,"top":0.20277777,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.43476564,"top":0.20277777,"width":0.011328125,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.44804686,"top":0.2013889,"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.45664063,"top":0.2013889,"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":"Execute","depth":4,"bounds":{"left":0.46679688,"top":0.10763889,"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.47695312,"top":0.10763889,"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.48984376,"top":0.10763889,"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.5,"top":0.10763889,"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.5101563,"top":0.10763889,"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.52304685,"top":0.10763889,"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.5359375,"top":0.10763889,"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.5671875,"top":0.10763889,"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.5800781,"top":0.10763889,"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.6917969,"top":0.10763889,"width":0.033203125,"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":"26","depth":4,"bounds":{"left":0.6417969,"top":0.12916666,"width":0.012109375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.65625,"top":0.12916666,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"22","depth":4,"bounds":{"left":0.66796875,"top":0.12916666,"width":0.01171875,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.6820313,"top":0.12916666,"width":0.009375,"height":0.013194445},"role_description":"text"},{"role":"AXStaticText","text":"104","depth":4,"bounds":{"left":0.69375,"top":0.12916666,"width":0.0140625,"height":0.013194445},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.7097656,"top":0.12777779,"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.12777779,"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 team_features where team_id = 1;\n\nSELECT * FROM teams WHERE name LIKE '%Vixio%'; # 340,270,11922\nSELECT * FROM users WHERE team_id = 340; # 12015\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 340\nand sa.provider = 'salesforce';\n# and sa.provider = 'salesloft';\n\nselect * from crm_fields where crm_configuration_id = 270 and object_type = 'event';\n# 125558 - Event Type - Event_Type__c\n# 125552 - Event Status - Event_Status__c\n\nSELECT * FROM sidekick_settings WHERE team_id = 340;\n\nSELECT * FROM crm_field_values WHERE crm_field_id in (125552);\n\nselect * from activities where crm_configuration_id = 270\nand type = 'conference' and crm_provider_id IS NOT NULL\nand actual_start_time > '2024-09-16 09:00:00' order by scheduled_start_time;\n\nSELECT * FROM activities WHERE id = 20871677;\nSELECT * FROM crm_field_data WHERE activity_id = 20871677;\n\nselect * from crm_layouts where crm_configuration_id = 270;\nselect * from crm_layout_entities where crm_layout_id in (886,887);\n\nSELECT * FROM crm_configurations WHERE id = 270;\n\nselect * from playbooks where team_id = 340; # 1514\nselect * from groups where team_id = 340;\nSELECT * FROM crm_fields WHERE id IN (125393, 125401);\n\nselect g.name as 'team name', p.name as 'playbook name', f.label as 'activity type field' from groups g\njoin playbooks p on g.playbook_id = p.id\njoin crm_fields f on p.activity_field_id = f.id\nwhere g.team_id = 340;\n\nSELECT * FROM activities WHERE uuid_to_bin('0c180357-67d2-419e-a8c3-b832a3490770') = uuid; # 20448716\nselect * from crm_field_data where object_id = 20448716;\n\nselect * from activities where crm_configuration_id = 270 and provider = 'salesloft' order by id desc;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%CybSafe%'; # 343,273,12008\nselect * from opportunities where team_id = 343;\nselect * from opportunities where team_id = 343 and crm_provider_id = '18099102526';\nselect * from opportunities where team_id = 343 and account_id = 945217482;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 343\nand sa.provider = 'hubspot';\n\nselect * from accounts where team_id = 343 order by name asc;\n\nselect * from stages where crm_configuration_id = 273 and type = 'opportunity';\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Voyado%'; # 353,283,12143\nSELECT * FROM activities WHERE crm_configuration_id = 283 and account_id = 3777844 order by id desc;\nSELECT * FROM accounts WHERE team_id = 353 AND name LIKE '%Salesloft%';\nSELECT * FROM activities WHERE id = 20717903;\n\nselect * from participants where activity_id IN (20929172,20928605,20928468,20926272,20926271,20926270,20926269,20916499,20916454,20916436,20916435,20900015,20900014,20900013,20897312,20897243,20897241,20897237,20897232,20897229,20893648,20893231,20893230,20893229,20893228,20889784,20885039,20885038,20885037,20885036,20885035,20882728,20882708,20882703,20882702,20869828,20869811,20869806,20869801,20869799,20869798,20869796,20869795,20869794,20869761,20869760,20869759,20868688,20868687,20850340,20847195,20841710,20833967,20827021,20825307,20825305,20825297,20824615,20824400,20823927,20821760,20795588,20794233,20794057,20793710,20785811,20781789,20781394,20781307,20762651,20758453,20758282,20757323,20756643,20756636,20756629,20756627,20756606,20756605,20756604,20756603,20756602,20756600,20756599,20756598,20756595,20756594,20756589,20756587,20756577,20756573,20748918,20748386,20748385,20748384,20748383,20748382,20748381,20748380,20748379,20748377,20748375,20748373,20743301,20717905,20717904,20717903,20717901,20717899);\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 353\nand sa.provider = 'salesforce';\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%modern world business solutions%'; # 345,275,12016, l.atkinson@mwbsolutions.co.uk\nSELECT * FROM activities WHERE uuid_to_bin('3921d399-3fef-4609-a291-b0097a166d43') = uuid;\n# id: 20940638, user: 12022, contact: 5305871\nSELECT * FROM activity_summary_logs WHERE activity_id = 20940638;\nselect * from contacts where team_id = 345 and crm_provider_id = '30891432415' order by name asc; # 5305871\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 345\nand sa.provider = 'hubspot';\n\nselect * from users where team_id = 345 and id = 12022;\nSELECT * FROM crm_profiles WHERE user_id = 12022;\nSELECT * FROM participants WHERE activity_id = 20940638;\nSELECT * FROM users u\nJOIN crm_profiles cp ON u.id = cp.user_id\nWHERE u.team_id = 345;\n\nselect * from contacts where team_id = 345 and crm_provider_id = '30880813535' order by name desc; # 5305871\n\nselect * from team_features where team_id = 345;\nSELECT * FROM activities WHERE uuid_to_bin('11701e2d-2f82-4dab-a616-1db4fad238df') = uuid; # 21115197\nSELECT * FROM participants WHERE activity_id = 20897406;\n\n\n\nSELECT * FROM activities WHERE uuid_to_bin('63ba55cd-1abc-447d-83da-0137000005b7') = uuid; # 20953912\nSELECT * FROM activities WHERE crm_configuration_id = 275 and provider = 'ringcentral' and title like '%1252629100%';\n\n\nSELECT * FROM activities WHERE id = 20946641;\nSELECT * FROM crm_profiles WHERE user_id = 10211;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Lunio%'; # 120,97,10984, triger@lunio.ai\nSELECT * FROM opportunities WHERE crm_configuration_id = 97 and crm_provider_id = '006N1000006c5PpIAI';\nselect * from stages where crm_configuration_id = 97 and type = 'opportunity';\nselect * from opportunities where team_id = 120;\n\n\nselect * from crm_configurations crm join teams t on crm.id = t.crm_id\nwhere 1=1\nAND t.current_billing_plan IS NOT NULL\nAND crm.auto_sync_activity = 0\nand crm.provider = 'hubspot';\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Exclaimer%'; # 270,205,10053,james.lewendon@exclaimer.com\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 270\nand sa.provider = 'salesforce';\nSELECT * FROM activities WHERE uuid_to_bin('b54df794-2a9a-4957-8d80-09a600ead5f8') = uuid; # 21637956\nSELECT * FROM crm_profiles WHERE user_id = 11446;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Cygnetise%'; # 372,300,12554, alex.chikly@cygnetise.com\nselect * from playbooks where team_id = 372;\nselect * from crm_fields where crm_configuration_id = 300 and object_type = 'event'; # 141340\nSELECT * FROM crm_field_values WHERE crm_field_id = 141340;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 372\nand sa.provider = 'salesforce';\n\nselect * from crm_profiles where crm_configuration_id = 300;\nSELECT * FROM crm_configurations WHERE team_id = 372;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Planday%'; # 291,242,11501,mfa@planday.com\nSELECT * FROM opportunities WHERE team_id = 291 and crm_provider_id = '006bG000005DO86QAG'; # 3207756\nselect * from crm_field_data where object_id = 3207756;\nSELECT * FROM crm_fields WHERE id = 111834;\n\nselect f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value\nFROM crm_fields f\nJOIN crm_field_data fd ON f.id = fd.crm_field_id\nWHERE f.crm_configuration_id = 242\nAND f.object_type = 'opportunity'\nAND fd.object_id IN (3207756)\nORDER BY fd.object_id, fd.updated_at;\n\nSELECT * FROM crm_configurations WHERE auto_connect = 1;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Tour%'; # 187,209,8150,salesforce-admin@tourlane.com\nselect * from group_deal_risk_types drgt join groups g on drgt.group_id = g.id\nwhere g.team_id = 187;\n\nselect * from `groups` where team_id = 187;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 187\nand sa.provider = 'salesforce';\n\n# Destination - 98870 - Destination__c\n# Stage - 79014 - StageName\n# Land Arrangement - 98856 - Land_Arrangement__c\n# Flight - 98848 - Flight__c\n# Last activity date - 98812 - LastActivityDate\n# Last modified date - 98809 - LastModifiedDate\n# Last inbound mail timestamp - 99151 - Last_Inbound_Mail_Timestamp__c\n# next call - 98864 - Next_Call__c\n\nselect * from crm_fields where crm_configuration_id = 209 and object_type = 'opportunity';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 209;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 682;\n\nselect * from opportunities where team_id = 187 and name LIKE'%Muriel Sal%';\nselect * from opportunities where team_id = 187 and user_id = 9951 and is_closed = 0;\nselect * from activities where opportunity_id = 3538248;\n\nSELECT * FROM crm_profiles WHERE user_id = 8150;\n\nselect * from deal_risks where opportunity_id = 3538248;\n\nselect * from teams where crm_id IS NULL;\n\nSELECT opp.id AS opportunity_id,\n u.group_id AS group_id,\n MAX(\n CASE\n WHEN a.type IN (\"sms-inbound\", \"sms-outbound\") THEN a.created_at\n ELSE a.actual_end_time\n END) as last_date\nFROM opportunities opp\nleft join activities a on a.opportunity_id = opp.id\ninner join users u on opp.user_id = u.id\nwhere opp.user_id IN (9951)\n\nAND opp.is_closed = 0\nand a.status IN ('completed', 'received', 'delivered') OR a.status IS NULL\ngroup by opp.id;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Cybsafe%'; # 343,301,12008,polly.morphew@cybsafe.com\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 343\nand sa.provider = 'hubspot';\n\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 301;\nSELECT * FROM contacts WHERE id = 6612363;\nSELECT * FROM accounts WHERE id = 4235676;\nSELECT * FROM opportunities WHERE crm_configuration_id = 301 and crm_provider_id = 32983784868;\nselect * from opportunity_stages where opportunity_id = 4503759;\n# SELECT * FROM opportunities WHERE id = 4569937;\n\nselect * from activities where crm_configuration_id = 301;\nSELECT * FROM activities WHERE uuid_to_bin('d3b2b28b-c3d0-4c2d-8ed0-eef42855278a') = uuid; # 26330370\nSELECT * FROM participants WHERE activity_id = 26330370;\n\nSELECT * FROM teams WHERE id = 375;\nselect * from playbooks where team_id = 375;\n\nselect * from stages where crm_configuration_id = 301 and type = 'opportunity';\n\nselect * from teams;\nselect * from contact_roles;\n\nSELECT * FROM opportunities WHERE team_id = 343 and user_id = 12871 and close_date >= '2024-11-01';\n\nselect * from users u join crm_profiles cp on cp.user_id = u.id where u.team_id = 343;\n\nSELECT * FROM crm_field_data WHERE object_id = 3771706;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 343\nand sa.provider = 'hubspot';\n\nSELECT * FROM crm_fields WHERE crm_configuration_id = 301 and object_type = 'opportunity'\nand crm_provider_id LIKE \"%traffic_light%\";\nSELECT * FROM crm_field_values WHERE crm_field_id IN (144020,144048,144111,144113,144126,144481,144508,144531);\n\nSELECT fd.* FROM opportunities o\nJOIN crm_field_data fd ON o.id = fd.object_id\nWHERE o.team_id = 343\n# and o.user_id IS NOT NULL\nand fd.crm_field_id IN (144020,144048,144111,144113,144126,144481,144508,144531)\nand fd.value != ''\norder by value desc\n# group by o.id\n;\n\nSELECT * FROM opportunities WHERE id = 3769843;\n\nSELECT * FROM teams WHERE name LIKE '%Tour%'; # 187,209,8150, salesforce-admin@tourlane.com\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 209;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 682;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Funding Circle%'; # 220,177,8603,aswini.mishra@fundingcircle.com\nSELECT * FROM activities WHERE uuid_to_bin('7a40e99b-3b37-4bb1-b983-325b81801c01') = uuid; # 23139839\n\n\nSELECT * FROM opportunities WHERE id = 3855992;\n\nSELECT * FROM users WHERE name LIKE '%Angus Pollard%'; # 8988\n\nSELECT * FROM teams WHERE name LIKE '%Story Terrace%'; # 379, 307, 12894\nSELECT * FROM crm_fields WHERE crm_configuration_id = 307 and object_type != 'opportunity';\n\nselect * from contacts where team_id = 379 and name like '%bebro%'; # 5874411, crm: 77229348507\nSELECT * FROM crm_field_data WHERE object_id = 5874411;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 379\nand sa.provider = 'hubspot';\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%mentio%'; # 117, 94, 6371, nikhil.kumar@mention-me.com\nSELECT * FROM activities WHERE uuid_to_bin('82939311-1af0-4506-8546-21e8d1fdf2c1') = uuid;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Tourlane%'; # 187, 209, 8150, salesforce-admin@tourlane.com\nSELECT * FROM opportunities WHERE team_id = 187 and crm_provider_id = '006Se000008xfvNIAQ'; # 3537793\nselect * from generic_ai_prompts where subject_id = 3537793;\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Lunio%'; # 120, 97, 10984, triger@lunio.ai\nSELECT * FROM crm_configurations WHERE id = 97;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 97;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 355;\nSELECT * FROM crm_fields WHERE id = 32682;\n\nselect cfd.value, o.* from opportunities o\njoin crm_field_data cfd on o.id = cfd.object_id and cfd.crm_field_id = 32682\nwhere team_id = 120\nand cfd.value != ''\n;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 120\nand sa.provider = 'salesforce';\n\nselect * from opportunities where team_id = 120 and crm_provider_id = '006N1000007X8MAIA0';\nSELECT * FROM crm_field_data WHERE object_id = 2313439;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE id = 410;\nSELECT * FROM teams WHERE name LIKE '%Local Business Oxford%';\nselect * from scorecards where team_id = 410;\nselect * from scorecard_rules;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Funding%'; # 220, 177, 8603, aswini.mishra@fundingcircle.com\nselect * from activities a\njoin opportunities o on a.opportunity_id = o.id\njoin users u on o.user_id = u.id\nwhere a.crm_configuration_id = 177 and a.type LIKE '%email-out%'\n# and a.actual_end_time > '2024-12-16 00:00:00'\n# and o.remotely_created_at > '2024-12-01 00:00:00'\n# and u.group_id = 1014\nand u.id = 9021\norder by a.id desc;\nSELECT * FROM opportunities WHERE id in (3981384,4017346);\nSELECT * FROM users WHERE team_id = 220 and id IN (8775, 11435);\n\nselect * from users where id = 9021;\nselect * from inboxes where user_id = 9021;\n\nselect * from inbox_emails where inbox_id = 1349 and email_date > '2024-12-18 00:00:00';\n\nselect * from email_messages where team_id = 220\nand orig_date > '2024-12-16 00:00:00' and orig_date < '2024-12-19 00:00:00'\nand subject LIKE '%Personal%'\n# and 'from' = 'credit@fundingcircle.com'\n;\n\nselect * from activities a\njoin opportunities o on a.opportunity_id = o.id\nwhere a.user_id = 9021 and a.type LIKE '%email-out%'\nand a.actual_end_time > '2024-12-18 00:00:00'\nand o.user_id IS NOT NULL\nand o.remotely_created_at > '2024-12-01 00:00:00'\norder by a.id desc;\n\nSELECT * FROM opportunities WHERE team_id = 220 and name LIKE '%Right Car move Limited%' and id = 3966852;\nselect * from activities where crm_configuration_id = 177 and type LIKE '%email%' and opportunity_id = 3966852 order by id desc;\n\nselect * from team_settings where name IN ('useCloseDate');\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Hurree%'; # 104, 81, 6175, jfarrell@hurree.co\nSELECT * FROM opportunities WHERE team_id = 104 and name = 'PropOp';\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 104\nand sa.provider = 'hubspot';\n\nselect * from crm_configurations where last_synced_at > '2025-01-19 01:00:00'\nselect * from teams where crm_id IS NULL;\n\nselect t.name as 'team', u.name as 'owner', u.email, u.phone\nfrom teams t\njoin activity_providers ap on t.id = ap.team_id\njoin users u on t.owner_id = u.id\nwhere 1=1\n and t.status = 'active'\n and ap.is_enabled = 1\n# and u.status = 1\n and ap.provider = 'ms-teams';\n\nselect * from crm_configurations where provider = 'bullhorn'; # 344\nSELECT * FROM teams WHERE id = 442; # 14293\nselect * from users where team_id = 442;\nselect * from social_accounts sa where sa.sociable_id = 14293;\nselect * from invitations where team_id = 442;\n\n# ********************************************************************************************************\nSELECT * FROM users WHERE email LIKE '%nea.liikamaa@eletive.com%'; # 14022\nSELECT * FROM teams WHERE id = 429;\nselect * from opportunities where team_id = 429 and crm_provider_id IN (16157415775, 22246219645);\nselect * from activities where opportunity_id in (4340436,4353519);\n\nselect * from transcription where activity_id IN (25630961,25381771);\nselect * from generic_ai_prompts where subject_id IN (4353519);\n\nSELECT\n a.id as activity_id,\n a.opportunity_id,\n a.type as activity_type,\n a.language,\n CONCAT(a.title, a.description) AS mail_content,\n e.from AS mail_from,\n e.to AS mail_to,\n e.subject AS mail_subject,\n e.body AS mail_body,\n p.type as prompt_type,\n p.status as prompt_status,\n p.content AS prompt_content,\n a.actual_start_time as created_at\nFROM activities a\n LEFT JOIN ai_prompts p ON a.transcription_id = p.transcription_id AND p.deleted_at IS NULL\n LEFT JOIN email_messages e ON a.id = e.activity_id\nWHERE a.actual_start_time > '2024-01-01 00:00:00'\n AND a.opportunity_id IN (4353519)\n AND a.status IN ('completed', 'received', 'delivered')\n AND a.deleted_at IS NULL\n AND a.type NOT IN ('sms-inbound', 'sms-outbound')\nORDER BY a.opportunity_id ASC, a.id ASC;\n\nSELECT * FROM users WHERE name LIKE '%George Fierstone%'; # 14293\nSELECT * FROM teams WHERE id = 442;\nSELECT * FROM crm_configurations WHERE id = 344;\nselect * from team_features where team_id = 442;\nselect * from groups where team_id = 442;\nselect * from playbooks where team_id = 442;\nselect * from playbook_categories where playbook_id = 1729;\nselect * from crm_fields where crm_configuration_id = 344 and id = 172024;\nSELECT * FROM crm_field_values WHERE crm_field_id = 172024;\nselect * from crm_layouts where crm_configuration_id = 344;\nselect * from playbook_layouts where playbook_id = 1729;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Learning%'; # 260, 221, 9444\n\nselect s.*\n# , s.sent_at, u.name, a.*\nfrom activity_summary_logs s\ninner join activities a on a.id = s.activity_id\ninner join users u on u.id = a.user_id\nwhere a.crm_configuration_id = 356\nand s.sent_at > date_sub(now(), interval 60 day)\norder by a.actual_end_time desc;\n\nselect * from activities a\n# inner join activity_summary_logs s on s.activity_id = a.id\nwhere a.crm_configuration_id = 356 and a.actual_end_time > date_sub(now(), interval 60 day)\n# and a.crm_provider_id is not null\n# and provider <> 'ringcentral'\nand status = 'completed'\norder by a.actual_end_time desc;\n\nselect * from teams order by id desc; # 17328, 32, 17830, integration-account@jiminny.com\nSELECT * FROM users;\nSELECT * FROM users where team_id = 260 and status = 1; # 201 - 150 active\nSELECT * FROM teams WHERE id = 260;\nselect * from team_settings where team_id = 260;\nselect * from crm_configurations where team_id = 260;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 356;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1184;\n\nselect * from accounts where crm_configuration_id = 221 order by id desc; # 7000\nselect * from leads where crm_configuration_id = 221 order by id desc; # 0\nselect * from contacts where crm_configuration_id = 221 order by id desc; # 200 000\nselect * from opportunities where crm_configuration_id = 221 order by id desc; # 0\nselect * from crm_profiles where crm_configuration_id = 221 order by id desc; # 23\nselect * from crm_fields where crm_configuration_id = 221;\nselect * from crm_field_values where crm_field_id = 5302 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 221 order by id desc;\nselect * from stages where crm_configuration_id = 221 order by id desc;\n\nselect * from accounts where crm_configuration_id = 356 order by id desc; # 7000\nselect * from leads where crm_configuration_id = 356 order by id desc; # 0\nselect * from contacts where crm_configuration_id = 356 order by id desc; # 200 000\nselect * from opportunities where crm_configuration_id = 356 order by id desc; # 0\nselect * from crm_profiles where crm_configuration_id = 356 order by id desc; # 23\nselect * from crm_fields where crm_configuration_id = 356;\nselect * from crm_field_values where crm_field_id = 5302 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 356 order by id desc;\nselect * from stages where crm_configuration_id = 356 order by id desc;\n\nselect * from playbooks where team_id = 260 order by id desc; # 4 (2 deleted)\nselect * from groups where team_id = 260 order by id desc; # 27 groups, (2 deleted)\nselect * from playbook_layouts where playbook_id IN (1410,1409,1276,1254); # 4\nselect ce.* from calendars c\njoin users u on c.user_id = u.id\njoin calendar_events ce on c.id = ce.calendar_id\nwhere u.team_id = 260\nand (ce.start_time > '2025-02-21 00:00:00')\n;\n# calendar events 1207\n#\n\nselect * from opportunities where team_id = 260;\nSELECT * FROM crm_field_data WHERE object_id = 4696496;\n\nselect * from activities where crm_configuration_id = 356 and crm_provider_id IS NOT NULL;\nselect * from activities where crm_configuration_id IN (221) and provider NOT IN ('ms-teams', 'uploader', 'zoom-bot')\n# and type = 'conference' and status = 'scheduled' and activities.is_internal = 0\nand created_at > '2024-03-01 00:00:00'\norder by id desc; # 880 000, ringcentral, avaya\nSELECT * FROM participants WHERE activity_id = 26371744;\n\n# all activities 942 000 +\n# conference 7385 - scheduled 984 - external 343\n\nselect * from activities where id = 26321812;\nselect * from participants where activity_id = 26321812;\nselect * from participants where activity_id in (26414510,26414514,26414516,26414604,26414653,26414655);\nselect * from leads where id in (720428,689175,731546,645866,621037);\n\nselect * from users where id = 13841;\nselect * from opportunities where user_id = 9541;\nselect * from stages where id = 15900;\n\nselect * from accounts where\n# id IN (4160055,5053725,4965303,4896434)\nid in (4584518,3249934,3218025,3891133,3399450,4172999,4485161,3101785,4587203,3070816,2870343,2870341,3563940,4550846,3424464,3249963,2870342)\n;\n\nselect * from activities where id = 26654935;\nSELECT * FROM opportunities WHERE id = 4803458;\n\nSELECT * FROM opportunities where team_id = 260 and user_id = 13841 AND stage_id = 15900;\nSELECT id, uuid, provider, type, lead_id, account_id, contact_id, opportunity_id, stage_id, status, recording_state, title, actual_start_time, actual_end_time\nFROM activities WHERE user_id = 13841 AND opportunity_id IN (4729783, 4731717, 4731726, 4732064, 4732849, 4803458, 4813213);\n\nSELECT DISTINCT\n o.id, o.stage_id, s.name, a.title,\n a.*\nFROM activities a\n# INNER JOIN tracks t ON a.id = t.activity_id\nINNER JOIN users u ON a.user_id = u.id\nINNER JOIN teams team ON u.team_id = team.id\nINNER JOIN groups g ON u.group_id = g.id\nINNER JOIN opportunities o ON a.opportunity_id = o.id\nINNER JOIN stages s ON o.stage_id = s.id\nWHERE\n a.crm_configuration_id = 356\n AND a.status IN ('completed', 'failed')\n AND a.recording_state != 'stopped'\n# and a.user_id = 13841\n AND u.uuid = uuid_to_bin('6f40e4b8-c340-4059-b4ac-1728e87ea99e')\n AND team.uuid = uuid_to_bin('a607fba7-452e-4683-b2af-00d6cb52c93c')\n AND g.uuid = uuid_to_bin('b5d69e40-24a0-4c16-810b-5fa462299f94')\n\n AND a.type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n# AND t.type IN ('audio', 'video')\n AND (\n (a.actual_start_time BETWEEN '2025-03-13 00:00:00' AND '2025-03-18 07:59:59')\n OR\n (\n a.actual_start_time IS NULL\n AND a.type IN ('sms-outbound', 'sms-inbound')\n AND a.created_at BETWEEN '2025-03-13 00:00:00' AND '2025-03-18 07:59:59'\n )\n )\n AND (\n a.is_private = 0\n OR (\n a.is_private = 1\n AND u.uuid = uuid_to_bin('6f40e4b8-c340-4059-b4ac-1728e87ea99e')\n )\n )\n AND (\n# s.id = 15900\n s.uuid = uuid_to_bin('04ca1c26-c666-4268-a129-419c0acffd73')\n OR s.uuid IS NULL -- Include records without opportunity stage\n )\n\nORDER BY a.actual_end_time DESC;\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Lead Forensics%'; # 190, 162, 8474, willsc@leadforensics.com\nSELECT * FROM users WHERE team_id = 190;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 190\nand sa.provider = 'hubspot';\n\nselect * from role_user where user_id = 8474;\n\nselect * from crm_configurations where provider = 'bullhorn';\n\nSELECT * FROM opportunities WHERE uuid_to_bin('94578249-65ec-4205-90f2-7d1a7d5ab64a') = uuid;\nSELECT * FROM users WHERE uuid_to_bin('26dbadeb-926f-4150-b11b-771b9d4c2f9a') = uuid;\n\nSELECT * FROM opportunities WHERE id = 4732493;\nselect * from activities where opportunity_id = 4732493;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE id = 443; # 358, 14315, andrea.romano@correrenaturale.com\nSELECT * FROM opportunities WHERE team_id = 443;\n\nSELECT a.id, a.type, a.user_id, a.status, a.deleted_at, u.name, u.email, u.team_id as activity_team_id, u.status, u.deleted_at, t.name, t.status, s.team_id as stage_team_id\nFROM activities AS a\nJOIN stages AS s ON a.stage_id = s.id\nJOIN users AS u ON u.id = a.user_id\nJOIN teams AS t ON t.id = s.team_id\nWHERE u.team_id <> s.team_id and t.id > 135;\n\n\nSELECT\n crm_configuration_id,\n crm_provider_id,\n COUNT(*) as duplicate_count,\n GROUP_CONCAT(id) as stage_ids,\n GROUP_CONCAT(name) as stage_names\nFROM stages\nGROUP BY crm_configuration_id, crm_provider_id\nHAVING COUNT(*) > 1\nORDER BY duplicate_count DESC;\n\nselect * from stages where id IN (14898,14907);\n\nselect * from business_processes;\n\nSELECT *\nFROM crm_configurations\nWHERE team_id IN (\n SELECT team_id\n FROM crm_configurations\n GROUP BY team_id\n HAVING COUNT(*) > 1\n)\nORDER BY team_id;\n\nSELECT *\nFROM teams\nWHERE crm_id IN (\n SELECT crm_id\n FROM teams\n GROUP BY crm_id\n HAVING COUNT(*) > 1\n)\nORDER BY crm_id;\n\n# ***************************************************************************\nselect * from crm_configurations where provider = 'integration-app';\nSELECT * FROM teams WHERE id = 443; # Correre Naturale 358 14315 andrea.romano@correrenaturale.com\nselect * from activities where crm_configuration_id = 358 order by actual_end_time desc;\nselect id, uuid, actual_end_time, crm_provider_id, is_internal, playbook_category_id, type, user_id, lead_id, contact_id, account_id, opportunity_id, status, title from activities where crm_configuration_id = 358 order by actual_end_time desc;\nselect * from team_features where team_id = 358;\nselect * from activity_summary_logs;\n\nselect * from teams where id = 406;\n\n# ************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Sportfive%'; # 267, 202, 14637, srv.salesforce@sportfive.com\nselect * from activities where crm_configuration_id = 202 order by actual_end_time desc;\n\nSELECT * FROM users where id = 14637;\nSELECT * FROM teams where id = 267;\nSELECT * FROM groups where id = 1118;\n\nselect g.name, a.title, uuid_from_bin(a.uuid), a.external_id, a.status, a.recording_state, a.recording_reason_code, a.scheduled_start_time, a.scheduled_end_time, a.actual_start_time, a.actual_end_time from activities a\ninner join users u on u.id = a.user_id\ninner join groups g on g.id = u.group_id\nwhere a.crm_configuration_id = 202\nand a.is_internal = 0\nand (a.scheduled_start_time between '2025-03-19 00:00:00' and '2025-03-21 00:00:00')\nand a.type = 'conference'\nand a.status != 'completed'\nand a.external_id is not null\norder by a.scheduled_start_time desc;\n\nSELECT * FROM activities\nWHERE crm_configuration_id = 202\n AND status IN ('completed', 'failed')\n AND recording_state != 'stopped'\n AND type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n AND (is_private = 0 OR user_id = 14637)\n AND (\n (\n actual_start_time BETWEEN '2025-03-12 12:00:00' AND '2025-03-24 11:59:59'\n ) OR (\n actual_start_time IS NULL\n AND type IN ('sms-outbound', 'sms-inbound')\n AND created_at BETWEEN '2025-03-12 12:00:00' AND '2025-03-24 11:59:59'\n )\n )\n AND NOT EXISTS (\n SELECT 1\n FROM tracks\n WHERE\n tracks.activity_id = activities.id\n AND tracks.type IN ('audio', 'video')\n )\nORDER BY actual_end_time DESC;\n\nSELECT DISTINCT\n a.*\nFROM activities a\nINNER JOIN tracks t ON a.id = t.activity_id\nINNER JOIN users u ON a.user_id = u.id\nINNER JOIN teams team ON u.team_id = team.id\nWHERE\n a.crm_configuration_id = 202\n AND a.status IN ('completed', 'failed')\n AND a.recording_state != 'stopped'\n# and a.user_id = 14637\n AND a.type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n# AND t.type IN ('audio', 'video')\n AND (\n (a.actual_start_time BETWEEN '2025-03-12 12:00:00' AND '2025-03-24 11:59:59')\n OR\n (\n a.actual_start_time IS NULL\n AND a.type IN ('sms-outbound', 'sms-inbound')\n AND a.created_at BETWEEN '2025-03-12 12:00:00' AND '2025-03-24 11:59:59'\n )\n )\n AND (\n a.is_private = 0\n OR (\n a.is_private = 1\n AND a.user_id = 14637\n )\n )\n\nORDER BY a.actual_end_time DESC\n;\n\nSELECT DISTINCT a.*\nFROM activities a\nINNER JOIN users u ON a.user_id = u.id\nINNER JOIN teams t ON u.team_id = t.id\n# INNER JOIN tracks tr ON a.id = tr.activity_id\n# INNER JOIN groups g ON u.group_id = g.id\nWHERE 1=1\n AND t.id = 267\n# AND t.uuid = uuid_to_bin('aed4927b-f1ea-499e-94c3-83762fd233e8')\n AND a.status IN ('completed', 'failed')\n AND a.recording_state != 'stopped'\n AND a.type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n# AND tr.type NOT IN ('audio', 'video')\n AND (\n a.is_private = 0\n OR a.user_id = 14637\n )\n AND (\n (a.actual_start_time BETWEEN '2025-03-19 00:00:00' AND '2025-03-21 23:59:59')\n OR (\n a.actual_start_time IS NULL\n AND a.type IN ('sms-outbound', 'sms-inbound')\n AND a.created_at BETWEEN '2025-03-19 00:00:00' AND '2025-03-21 23:59:59'\n )\n )\n# and NOT EXISTS (\n# SELECT 1\n# FROM tracks t\n# WHERE t.activity_id = a.id\n# AND t.type IN ('audio', 'video')\n# )\n\nORDER BY a.actual_end_time DESC;\n\nSELECT * FROM tracks WHERE activity_id = 26485995;\n\nselect a.is_private, a.title, uuid_from_bin(a.uuid), a.external_id, a.status, a.recording_state, a.recording_reason_code, a.scheduled_start_time, a.scheduled_end_time, a.actual_start_time, a.actual_end_time from activities a\ninner join users u on u.id = a.user_id\nwhere a.crm_configuration_id = 202\n# and a.is_internal = 0\nand (a.actual_start_time between '2025-03-19 00:00:00' and '2025-03-21 00:00:00')\nand a.type IN (\"softphone\",\"softphone-inbound\",\"conference\",\"sms-inbound\")\nand a.status IN ('completed', 'failed')\n# and a.external_id is not null\norder by a.actual_end_time desc;\n\nselect * from activities a where a.crm_configuration_id = 202\nand a.actual_start_time between '2025-03-20 00:00:00' and '2025-03-21 00:00:00'\n# AND a.type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n\nselect g.name, a.title, uuid_from_bin(a.uuid), a.external_id, a.status, a.recording_state, a.recording_reason_code, a.scheduled_start_time, a.scheduled_end_time, a.actual_start_time, a.actual_end_time from activities a\ninner join users u on u.id = a.user_id\ninner join groups g on g.id = u.group_id\nwhere a.crm_configuration_id = 202\nand a.is_internal = 0\nand (a.scheduled_start_time between '2025-03-19 00:00:00' and '2025-03-21 00:00:00')\nand a.type = 'conference'\nand a.status != 'completed'\nand a.external_id is not null\norder by a.scheduled_start_time desc;\n\nSELECT * FROM teams WHERE name LIKE '%Tourlane%';\nSELECT * FROM crm_fields WHERE crm_configuration_id = 209 and object_type = 'opportunity';\nSELECT * FROM crm_field_data WHERE crm_field_id = 98809;\n\nselect * from users where status = 1 AND timezone = 'MDT';\n\nselect * from opportunities where id = 3769814;\nselect * from deal_risks where opportunity_id = 3769814;\n\nselect cp.* from crm_profiles cp\njoin users u on cp.user_id = u.id\njoin crm_configurations crm on cp.crm_configuration_id = crm.id\nwhere crm.provider = 'hubspot' AND u.status = 1 AND log_notes != 'none';\n\nselect * from crm_fields where id = 154575;\n\nselect * from team_features where feature = 'SUPPORTS_SYNC_MISSING_CALL_DISPOSITIONS';\nSELECT * FROM teams WHERE id = 176; # crm 148\nselect * from activities where crm_configuration_id = 148 and provider = 'hubspot' order by id desc;\n\nselect * from activity_providers where provider = 'amazon-connect';\n\nselect * from crm_fields cf\njoin crm_configurations crm on crm.id = cf.crm_configuration_id\nwhere crm.provider = 'hubspot' and cf.object_type IN ('account', 'contact');\n\n# *********************************************************************************************\nSELECT * FROM users WHERE id IN (15415, 15418);\nSELECT * FROM groups WHERE id IN (1805,1806);\nSELECT * FROM playbooks WHERE id = 1860;\nSELECT * FROM playbook_categories WHERE id = 38634;\nSELECT * FROM crm_fields WHERE id = 189962;\n\nSELECT * FROM teams WHERE name = 'Pulsar Group'; # 472, 380, 15138 raza.gilani@vuelio.com\n\nSELECT * FROM crm_profiles WHERE user_id = 15415;\nSELECT * FROM social_accounts WHERE sociable_id = 15415 and provider = 'salesforce';\n\nselect * from sidekick_settings where team_id = 472;\n\nSELECT * FROM activities WHERE uuid_to_bin('452c58c7-b87c-4fdd-953e-d7af185e9588') = uuid; # 28617536, user: 15418\nSELECT * FROM activities WHERE uuid_to_bin('399114ee-d3a8-458c-bff5-5f654658db0a') = uuid; # 28344407, user: 15415\nSELECT * FROM activities WHERE uuid_to_bin('f0aa567f-0ab1-4bbb-96aa-37dcf184676b') = uuid; # 28580288, user: 15415\nSELECT * FROM activities WHERE uuid_to_bin('50c086b1-2770-4bca-b5ae-6bac22ec426b') = uuid; # 28566069, user: 15415\n\n# *********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%TeamTailor%'; # 109, 218, 13969, salesforce-integrations@teamtailor.com\nselect * from crm_configurations where id = 218;\nSELECT * FROM activities WHERE uuid_to_bin('e39b5857-7fdb-4f5a-951a-8d3ca69bb1b0') = uuid; # 28338765\nSELECT * FROM users WHERE id IN (13232, 13230);\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 109\nand sa.provider = 'salesforce';\n\n0057R00000EPL5HQAX Inez Ekblad\n\n1091cb81-5ea1-4951-a0ed-f00b568f0140 Triman Kaur\n\nSELECT * FROM crm_profiles WHERE user_id IN (13232, 13230);\n\n############################################################################################\nSELECT * FROM activities WHERE uuid_to_bin('675eeaeb-5681-42db-90bc-54c07a604408') = uuid; # 28655939 00UVg00000FLvnSMAT\nSELECT * FROM crm_field_data WHERE activity_id = 28655939;\nSELECT * FROM crm_fields WHERE id IN (94491,94493,94498);\nSELECT * FROM users WHERE id = 13658;\nSELECT * FROM teams WHERE id = 109;\nSELECT * FROM crm_configurations WHERE id = 218;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 109\nand sa.provider = 'salesforce';\n\n# ********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Strengthscope%'; # 481, 390, 15420, katy.holden@strengthscope.comk\nSELECT * FROM stages WHERE crm_configuration_id = 390;\nselect * from business_processes where team_id = 481 and crm_configuration_id = 390;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 481\nand sa.provider = 'salesforce';\n\n\nSELECT * FROM users WHERE id = 15780; # team 462\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 462\nand sa.provider = 'hubspot';\n\n\nselect * from teams where id = 495;\nSELECT * FROM users WHERE id = 15794;\nselect * from social_accounts where sociable_id = 15794;\n\n# ********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Flight%'; # 427, 333, 13752\nSELECT * FROM accounts WHERE team_id = 427 and crm_provider_id = '668731000183444517';\n\n# ********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Group GTI%'; # 495, 407, 15794\nSELECT * FROM activities WHERE crm_configuration_id = 407\nand status = 'completed' and type = 'conference'\norder by id desc;\n\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 permission_role;\n\nselect * from activities where crm_configuration_id = 407 and status = 'completed' order by id desc;\nSELECT * FROM activities WHERE id = 29512773;\nSELECT * FROM activities WHERE id IN (29042721,28991325,29002874);\n\nSELECT al.* from activity_summary_logs al join activities a on a.id = al.activity_id\nwhere a.crm_configuration_id = 407\n# and a.id IN (29042721,28991325,29002874);\n\nSELECT * FROM users WHERE id = 15794;\nSELECT * FROM users WHERE team_id = 495;\nSELECT * FROM social_accounts WHERE sociable_id = 15794;\nSELECT * FROM opportunities WHERE team_id = 495 and name like '%OC:%';\nSELECT * FROM contacts WHERE team_id = 495;\nSELECT * FROM leads WHERE team_id = 495;\nSELECT * FROM accounts WHERE team_id = 495;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 407;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 407;\nSELECT * FROM crm_configurations WHERE id = 407;\nSELECT * FROM opportunities WHERE team_id = 495 and close_date BETWEEN '2025-06-01' AND '2025-07-01'\nand user_id IS NOT NULL and is_closed = 1 and is_won = 1;\n\n# ********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Hamilton Court FX LLP%'; # 249, 187, 10103\nSELECT * FROM activities WHERE uuid_to_bin('4659c2bb-9a49-484e-9327-a3d66f1e028c') = uuid; # 28951064\nSELECT * FROM crm_fields WHERE crm_configuration_id = 187 and object_type IN ('tasks', 'event');\n\n# *********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Checkstep%'; # 325, 256, 11753\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 325\nand sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('7be372e2-1916-4d79-a2f3-ca3db1346db3') = uuid; # 28611085\nSELECT * FROM activities WHERE uuid_to_bin('980f0336-840b-4185-a5a9-30cf8b0749a8') = uuid; # 28719733\nSELECT * FROM activity_summary_logs where activity_id = 28719733;\n\n# *************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Learning%'; # 260, 356, 9444\nSELECT * FROM activity_summary_logs where sent_at BETWEEN '2025-06-09 11:38:00' AND '2025-06-09 11:40:00';\nSELECT * FROM leads WHERE crm_configuration_id = 356 and crm_provider_id = '230045001502770504'; # 823630\nselect * from activities where crm_configuration_id = 356 and lead_id = 841732;\n\nSELECT * from activity_summary_logs al join activities a on a.id = al.activity_id\nwhere a.crm_configuration_id = 356;\n\nselect * from activities where crm_configuration_id = 356\nand actual_end_time between '2025-06-09 11:00:00' and '2025-06-09 12:00:00'\norder by id desc;\n\nselect * from accounts where crm_configuration_id = 356 and crm_provider_id = '230045001514403366' order by id desc;\nselect * from leads where crm_configuration_id = 356 and crm_provider_id = '230045001514275654' order by id desc;\nselect * from contacts where crm_configuration_id = 356 and crm_provider_id = '230045001514403366' order by id desc;\nselect * from opportunities where crm_configuration_id = 356 and crm_provider_id = '230045001514403366' order by id desc;\n\nselect * from team_features where team_id = 260;\nselect * from features where id IN (1,2,4,6,18,19,20,9,10,3,23,24,25,26,27);\n\nSELECT * FROM activities WHERE uuid_to_bin('7be372e2-1916-4d79-a2f3-ca3db1346db3') = uuid;\n\nselect * from crm_fields;\nselect * from crm_layout_entities;\n\nSELECT * FROM teams WHERE name LIKE '%Optable%';\n\n# *************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Teamtailor%'; # 109, 218, 13969\nSELECT * FROM crm_configurations WHERE id = 218;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 109\nand sa.provider = 'salesforce';\nSELECT * FROM activities WHERE uuid_to_bin('675eeaeb-5681-42db-90bc-54c07a604408') = uuid; # 28655939\nSELECT * FROM crm_field_data WHERE activity_id = 28655939;\nSELECT * FROM crm_fields WHERE id in (94491,94493,94498);\n\nselect * from teams where crm_id IS NULL;\n\nSELECT * FROM activities WHERE uuid_to_bin('71aa8a0c-9652-4ff6-bee7-d98ae60abef6') = uuid;\n\n# *************************************************************************************************\nselect * from team_domains where team_id = 399;\nSELECT * FROM teams WHERE name LIKE '%Rydoo%'; # 399, 318, 13207\n\nselect * from calendar_events where id = 5163781;\nSELECT * FROM activities WHERE uuid_to_bin('be2cbc52-7fda-46a0-9ae0-25d9553eafc0') = uuid; # 29443896\nSELECT * FROM participants WHERE activity_id = 29443896;\nselect * from contacts where crm_configuration_id = 318 and email = 'marianne.westeng@strawberry.no';\nselect * from leads where crm_configuration_id = 318 and email = 'marianne.westeng@strawberry.no';\n\nselect * from activities where user_id = 14937 order by created_at ;\n\nselect * from users where id = 14937;\n\nselect * from contacts where crm_configuration_id = 318 and email LIKE '%@strawberry.se';\nselect * from opportunities where crm_configuration_id = 318 and crm_provider_id = '006Sf00000D1WOAIA3';\n\nselect * from activities a join participants p on a.id = p.activity_id\nwhere crm_configuration_id = 318 and a.updated_at > '2025-06-23T08:18:43Z';\n\n# *************************************************************************************************\nSELECT * FROM opportunities WHERE team_id = 379 and crm_provider_id = '39334518886';\nSELECT * FROM opportunities WHERE team_id = 379 order by id desc;\nSELECT * FROM teams WHERE id = 379;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 379 and sociable_id = 13852\nand sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE id = 307;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 307;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1027;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 307\n and id IN (144750,144855,145158,155227);\n\nSELECT * FROM activities;\n\n\nselect * from activities\nwhere created_at > '2025-07-01 00:00:00'\n# and created_at < '2025-08-01 00:00:00'\nand type not in ('email-outbound', 'email-inbound')\nand account_id is null\nand contact_id is null\nand lead_id is null\nand opportunity_id is not null\n;\nSELECT * FROM activities WHERE id IN (25344155, 25344296, 25501909, 28692187);\nSELECT * FROM crm_configurations WHERE id in (335,301,200);\n\nselect * from crm_fields where crm_configuration_id = 230 and crm_provider_id = 'Age2__c';\n\nSELECT * FROM teams WHERE name LIKE '%Resights%';\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\n\nselect * from crm_configurations where provider = 'bullhorn'; # 344\nselect * from teams where id IN (442);\n\nselect * from activities\nwhere crm_configuration_id = 177\nand provider = 'amazon-connect'\n order by id desc;\n# and source <> 'gong';\n\nselect * from activity_providers where provider = 'amazon-connect';\n\nSELECT * FROM activities WHERE uuid_to_bin('cec1993b-a7e5-4164-b74d-d680ea51d2f2') = uuid;\n\n\nselect * from crm_configurations where store_transcript = 1;\nSELECT * FROM teams WHERE id IN (80);\n\n# *************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Sedna%'; # 277, 213, 12594\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 277\nand sa.provider = 'salesforce';\n\nselect * from activities where crm_configuration_id = 213 and account_id = 2511502;\n\nselect * from crm_configurations where id = 213;\n\nSELECT * FROM activities WHERE uuid_to_bin('35aa790a-8569-4544-8268-66f9a4a26804') = uuid; # 33981604\nSELECT * FROM participants WHERE activity_id = 33981604;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 337 and object_type = 'task';\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 431\nand sa.provider = 'salesforce';\nSELECT * FROM activities WHERE uuid_to_bin('b5476c7d-19a8-491b-869d-676ea1e857b6') = uuid; # 33997223\nselect * from activity_summary_logs where activity_id = 33997223;\nselect * from activity_notes where activity_id = 33997223;\n\n# ***********************************\nSELECT * FROM teams WHERE name LIKE '%Abode%';\n\n\nselect * from features;\nselect * from teams t\nwhere t.status = 'active'\nand id NOT IN (select team_id from team_features where feature_id = 9)\n;\n\n\nselect * from playbook_layouts where playbook_id = 1725;\nSELECT * FROM activities WHERE uuid_to_bin('65cc283c-4849-49e6-927f-4c281c8fea19') = uuid; # 34297473\nselect * from teams where id = 318;\nselect * from crm_configurations where team_id = 318;\nselect * from playbooks where team_id = 318;\nSELECT * FROM crm_layouts where crm_configuration_id = 381;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1259;\nSELECT * FROM crm_fields WHERE id IN (192938,192936,192939);\n\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1266;\nSELECT * FROM crm_fields WHERE id IN (192980,192991,192997,192998,193064,193067);\n\nSELECT * FROM activities WHERE uuid_to_bin('a902289b-285c-48eb-9cc2-6ad6c5d938f5') = uuid; # 34297533\n\n\n\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 927;\nSELECT * FROM crm_fields WHERE id IN (131668,131669,131670,131671,131676,131797);\n\nSELECT * FROM teams WHERE name LIKE '%Peripass%'; # 351, 281, 12124\nselect * from crm_layouts where crm_configuration_id = 281;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 927;\nselect * from crm_fields where crm_configuration_id = 281 and id in (131668,131669,131670,131671,131676,131797);\nselect * from opportunities where crm_configuration_id = 281;\n\nSELECT * FROM activities WHERE id IN (34211315, 34130075);\nSELECT * FROM crm_field_data WHERE object_id IN (34211315, 34130075);\n\nselect cf.crm_configuration_id, cle.crm_layout_id, cle.id, cf.id from crm_field_data cfd\njoin crm_layout_entities cle on cle.id = cfd.crm_layout_entity_id\njoin crm_fields cf on cle.crm_field_id = cf.id\nwhere cf.deleted_at IS NOT NULL\nGROUP BY cle.id, cf.id;\n\nselect * from crm_layouts where id IN (355);\nselect u.email, t.crm_id, t.* from teams t\njoin users u on u.id = t.owner_id\nwhere crm_id IN (97);\n\nSELECT * FROM crm_fields WHERE id = 96492;\n\nselect * from permissions;\nselect * from permission_role where permission_id = 247;\nselect * from roles;\n\nselect * from migrations;\n# *****************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('291e3c21-11cc-4728-aee7-6e4bedf86d72') = uuid; # 34262174\nSELECT * FROM crm_configurations WHERE id = 301;\nSELECT * FROM teams WHERE id = 343;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 343\nand sa.provider = 'hubspot';\n\nselect * from participants where activity_id = 34262174;\n\nselect * from contacts where crm_configuration_id = 301 and id = 6976326;\nselect * from accounts where crm_configuration_id = 301 and id IN (4647626, 4815829); # 30761335403\n\nselect * from activity_summary_logs where activity_id = 34262174;\n\nselect * from users where status = 1 AND timezone = 'EST';\n\n# ****************************************************************************\nSELECT * FROM users WHERE id = 13869;\nSELECT * FROM crm_configurations WHERE id = 320;\nSELECT * FROM teams WHERE id = 401;\n\nSELECT * FROM activities WHERE uuid_to_bin('2228c16f-10be-48d5-90d4-67385219dc01') = uuid; # 29670601\n\nSELECT * FROM accounts WHERE id = 7761483;\nSELECT * FROM opportunities WHERE id = 6051814;\n\nSELECT * FROM teams WHERE name LIKE '%Seedlegals%';\n\n;select * from opportunities where updated_at > '2025-10-11' AND crm_provider_id = '34713761166';\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 177;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 577;\nSELECT * FROM crm_fields WHERE id IN (68458,68459,68480,68497,68524,68530,68554,68618,68662,68781,68810,68898,68981,69049,97467);\n\nSELECT t.id, crm.id, t.name, crm.sync_objects, crm.provider, crm.last_synced_at FROM crm_configurations crm join teams t on t.crm_id = crm.id\nwhere t.status = 'active' AND crm.provider = 'hubspot' AND crm.last_synced_at < '2025-10-22 00:00:00';\n\nSELECT * FROM activities WHERE uuid_to_bin('fa09449f-cba9-496a-b8f3-865cd3c72351') = uuid;\nSELECT * FROM crm_configurations where id = 184;\nSELECT * FROM teams WHERE id = 246;\nSELECT * FROM social_accounts WHERE sociable_id = 9259 and provider = 'hubspot';\n\nSELECT * FROM users WHERE email LIKE '%rhian.old@bud.co.uk%'; # 17700\nSELECT * FROM teams WHERE id = 551;\n\nSELECT * FROM crm_configurations WHERE id = 471;\nSELECT * FROM activities WHERE crm_configuration_id = 471 and crm_provider_id IS NOT NULL;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 471;\nSELECT * FROM crm_fields WHERE id = 307260;\nSELECT * FROM crm_field_values WHERE crm_field_id = 307260;\n\nselect * from crm_layouts where crm_configuration_id = 471;\n\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1547;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1548;\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 = 551 and sa.provider = 'hubspot';\n\nSELECT * FROM teams WHERE name LIKE '%$PCS%';\n\n# ********************************************************************************************************\nselect * from crm_configurations crm\njoin teams t on t.crm_id = crm.id\nwhere t.status = 'active'\nand crm.provider = 'hubspot';\n\n# $slug = 'HUBSPOT_WEBHOOK_SYNC';\n# $team = Jiminny\\Models\\Team::find(2);\n# $feature = Feature::query()->where('slug', $slug)->first();\n# TeamFeature::query()->create(['feature_id' => $feature->getId(),'team_id' => $team->getId()]);\n\n# hubspot_webhook_metrics\n\nselect * from crm_configurations where id = 331; # 416\nSELECT * FROM teams WHERE id = 416;\nSELECT * FROM opportunities WHERE team_id = 190;\n\nSELECT * FROM teams WHERE name LIKE '%Lead Forensics%';\nSELECT sa.id,\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 = 190 and sa.provider = 'hubspot';\n\n\n\nSELECT * FROM teams WHERE name LIKE '%Rapaport%'; # 431, 337\nSELECT * FROM teams where id = 431;\nSELECT * FROM crm_configurations where team_id = 431;\nSELECT * FROM activity_providers where team_id = 431;\nSELECT * FROM activities where crm_configuration_id = 337 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\nSELECT sa.id,\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 = 431 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%BiP%'; # 401, 320\nSELECT * FROM teams where id = 401;\nSELECT * FROM crm_configurations where team_id = 401;\nSELECT * FROM activity_providers where team_id = 401;\nSELECT * FROM activities where crm_configuration_id = 320 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\nSELECT sa.id,\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 = 401 and sa.provider = 'salesforce';\n\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 307; # 379 - Story Terrace Inc , portalId: 3921157\nSELECT * FROM contacts WHERE team_id = 379 and updated_at > '2026-01-31 11:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 379 and updated_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 379 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 379 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; # 563 - LATUS Group (ad94d501-5d09-44fd-878f-ca3a9f8865c3) , portalId: 3904501\nSELECT * FROM opportunities WHERE team_id = 563 and updated_at > '2026-02-02 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 563 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 338; # 432 - Formalize , portalId: 9214205\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 = 432 and sa.provider = 'hubspot';\nSELECT * FROM opportunities WHERE team_id = 432 and updated_at > '2026-02-02 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 432 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 436; # 519 - Moxso , portalId: 25531989\nSELECT * FROM opportunities WHERE team_id = 519 and updated_at > '2026-02-02 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 519 and updated_at > '2026-02-04 11:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 96; # 119 - Nourish Care , portalId: 26617984\nSELECT * FROM opportunities WHERE team_id = 119 and updated_at > '2026-02-02 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 119 and updated_at > '2026-02-04 11:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 331; # 416 - The National College , portalId: 7213852\nSELECT * FROM opportunities WHERE team_id = 416 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 416 and updated_at > '2026-02-04 11:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 308; # 380 - Foodles , portalId: 7723616\nSELECT * FROM opportunities WHERE team_id = 380 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 380 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 379; # 471 - imat-uve , portalId: 9177354\nSELECT * FROM opportunities WHERE team_id = 471 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 471 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 465; # 545 - Spotler , portalId: 144759271\nSELECT * FROM opportunities WHERE team_id = 545 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 545 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 455; # 537 - indevis , portalId: 25666868\nSELECT * FROM opportunities WHERE team_id = 537 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 537 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 200; # 265 - Jobadder , portalId: 6426676\nSELECT * FROM opportunities WHERE team_id = 265 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 265 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 335; # 429 - Eletive , portalId: 6110563\nSELECT * FROM opportunities WHERE team_id = 429 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 429 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 363; # 456 - Global Group , portalId: 8901981\nSELECT * FROM opportunities WHERE team_id = 456 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 456 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 297; # 369 - Unbiased , portalId: 9229005\nSELECT * FROM opportunities WHERE team_id = 369 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 369 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 353; # 449 - Fuuse , portalId: 25781745\nSELECT * FROM opportunities WHERE team_id = 449 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 449 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 487; # 566 - Nimbus , portalId: 39982590\nSELECT * FROM opportunities WHERE team_id = 566 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 566 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 487;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1630;\nselect * from crm_fields where crm_configuration_id = 487 and\n(uuid_to_bin('4c6b2971-64d4-45b8-b377-427be758b5a5') = uuid or uuid_to_bin('59e368d8-65a0-4b77-b611-db37c99fbe68') = uuid);\nSELECT * FROM crm_field_values WHERE crm_field_id = 375177;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 420; # 506 - voiio , portalId: 145629154\nSELECT * FROM opportunities WHERE team_id = 506 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 506 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 479; # 558 - Momice , portalId: 535962\nSELECT * FROM opportunities WHERE team_id = 558 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 558 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 59; # 80 - Storyclash GmbH , portalId: 4268479\nSELECT * FROM opportunities WHERE team_id = 80 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 80 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 175; # 203 - Team iAM , portalId: 5534732\nSELECT * FROM opportunities WHERE team_id = 203 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 203 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 368; # 460 - OneTouch Health , portalId: 5534732183355\nSELECT * FROM opportunities WHERE team_id = 460 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 460 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\n\n\nselect * from users where id = 29643;\nSELECT * FROM crm_field_values WHERE crm_field_id = 375177;\n# ********************************************************************\nSELECT * FROM teams WHERE name LIKE '%Buynomics%'; # 462, 482, 14910\nSELECT * FROM activities WHERE crm_configuration_id = 482\nand type NOT IN ('email-inbound', 'email-outbound')\n# and description like '%The call focused on understanding Welch%'\norder 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 = 462 and sa.provider = 'salesforce';\n\nselect * from contacts where crm_configuration_id = 482 and name = 'Cyndall Hill'; # 15504749\nselect * from contacts where id = 10891096; # 482\nSELECT * FROM activities WHERE crm_configuration_id = 482\nand type NOT IN ('email-inbound', 'email-outbound')\nand contact_id = 15504749\norder by id desc;\n\nselect * from activities where id = 36793003; # 96cc7bc1-8622-4d27-92f4-baf664fc1a56, 00UOf00000PDdOXMA1\nselect * from transcription where id = 7646782;\nselect * from ai_prompts where transcription_id = 7646782;\n\n# ********************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('7a8471a3-847e-4822-802b-ddf426bbc252') = uuid; # 37370018\nSELECT * FROM activity_summary_logs WHERE activity_id = 37370018;\nSELECT * FROM teams WHERE id = 555;\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 = 555 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('7c17b8aa-09df-4f85-a0f7-51f47afd712d') = uuid; # 37395250\nSELECT * FROM activities WHERE uuid_to_bin('14d60388-260d-494b-aa0d-63fdb1c78026') = uuid; # 37395250\n\nSELECT a.* FROM activities a JOIN crm_configurations c on c.id = a.crm_configuration_id\nwhere a.type IN ('softphone', 'softphone-outbound') and c.provider = 'hubspot'\nand a.provider NOT IN ('hubspot')\n# and a.provider IN ('salesloft')\n# and c.id NOT IN (70)\n# and a.duration > 30\n# and actual_start_time > '2026-02-05 00:00:00'\norder by a.id desc;\n\nSELECT * FROM activities WHERE id = 37549787;\nSELECT * FROM crm_profiles WHERE user_id = 17613;\n\nSELECT * FROM crm_configurations WHERE id = 70;\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 = 93 and sa.provider = 'hubspot';\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations WHERE id = 373; # KPSBremen.de 465 # - no social account\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 = 465 and sa.provider = 'hubspot';\n\nselect * from crm_configurations where id = 494;\n\nSELECT * FROM teams WHERE name LIKE '%splose%'; # 572, 495, 18708\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 = 572 and sa.provider = 'pipedrive';\n\nselect * from opportunities where team_id = 572\n# and name like '%Onebright%'\n# and is_closed = 1 and is_won = 0\n order by id desc;\n\n\nselect * from users where deleted_at is null and status = 2;\n\nselect * from contacts where id = 17900517;\nselect * from accounts where id = 10109838;\nselect * from opportunities where id = 6955880;\n\nselect * from opportunity_contacts where opportunity_id = 6955880;\nselect * from opportunity_contacts where contact_id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nSELECT * FROM activities WHERE uuid_to_bin('adcb8331-5988-4353-834e-383a355abba2') = uuid; # 38056424, crm 104659682404\nselect * from teams where id = 456;\nSELECT * FROM crm_configurations WHERE id = 363;\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 = 456 and sa.provider = 'hubspot';\n\nselect * from crm_layouts where crm_configuration_id = 363;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id IN (1203, 1204, 1635);\nSELECT * FROM crm_fields WHERE id IN (181536, 181538, 213455);\n\nSELECT * FROM teams WHERE name LIKE '%Electric%'; # 342, 272, 12767\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 = 342 and sa.provider = 'pipedrive';\nSELECT * FROM opportunities WHERE crm_configuration_id = 272 and name like 'NORTHUMBRIA POL%'; # and updated_at > '2025-07-01 00:00:00';\nSELECT * FROM opportunities WHERE crm_configuration_id = 272 order by remotely_created_at asc; # and updated_at > '2025-07-01 00:00:00';\nSELECT * FROM opportunities WHERE crm_configuration_id = 272 and updated_at > '2026-01-01 00:00:00';\nSELECT * FROM crm_fields WHERE crm_configuration_id = 272 and object_type = 'opportunity';\nSELECT * FROM crm_field_values WHERE crm_field_id = 127164;\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 = 342 and sa.provider = 'pipedrive';\n\nSELECT * FROM teams WHERE id = 472;\nSELECT * FROM crm_configurations WHERE id = 380;\nselect * from activities where id = 38285673; # 38285673\nSELECT * FROM users WHERE id = 16942;\nSELECT * FROM groups WHERE id = 1964;\nSELECT * FROM playbooks WHERE id = 2033;\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 499; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1678;\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\n\nSELECT * FROM activities WHERE uuid_to_bin('96b1261f-2357-49f9-ab38-23ce12008ea0') = uuid;\n\nselect * from contacts c\nwhere c.crm_configuration_id = 370 order by c.updated_at desc;\n\nSELECT * FROM participants where activity_id = 38833541;\nSELECT * FROM participants where activity_id = 39216301;\nSELECT * FROM activity_summary_logs where activity_id = 39216301;\nSELECT * FROM activities WHERE uuid_to_bin('c7d99fbe-1fb1-41f2-8f4d-52e2bf70e1e9') = uuid; # 38833541, crm 478116564181\nSELECT * FROM activities WHERE uuid_to_bin('2e6ff4d3-9faa-447a-a8c1-9acde4d885ae') = uuid; # 39216301, crm 480171536586\nselect * from crm_profiles where crm_configuration_id = 319 and crm_provider_id = 525785080;\nselect * from opportunities where crm_configuration_id = 319 and crm_provider_id = 410150124747;\nselect * from accounts where crm_configuration_id = 319 and crm_provider_id = 47150650569;\nselect * from contacts where crm_configuration_id = 319 and crm_provider_id IN ('665587441856', '742723347700');\n# owner 13236 525785080\n# contact 1 16779180 665587441856 - activity - Alex Howes alex@supportroom.com created 2026-01-26\n# contact 2 19247563 742723347700 - ash@supportroom.com 2026-03-24\n# company 4176133 47150650569\n# deal 7100953 410150124747\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 = 400 and sa.provider = 'hubspot';\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556; # owner: 18101, crm: 477\nselect * from crm_configurations where id = 477;\nSELECT * FROM users WHERE id = 18101;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'integration-app';\n\nselect * from opportunities where id = 7594349;\nselect * from opportunity_stages where opportunity_id = 7594349 order by created_at desc;\nselect * from business_processes where id = 6024;\nselect * from business_process_stages where stage_id = 16352;\nselect * from business_process_stages where business_process_id = 6024;\nselect * from stages where team_id = 459;\nselect * from teams where id = 459;\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 = 459 and sa.provider = 'hubspot';\n\nSELECT os.stage_id, s.crm_provider_id, s.name, COUNT(*) as cnt\nFROM opportunity_stages os\nJOIN stages s ON s.id = os.stage_id\nWHERE os.opportunity_id = 7594349\nGROUP BY os.stage_id, s.crm_provider_id, s.name\nORDER BY cnt DESC;\n\nSELECT s.id, s.crm_provider_id, s.name, s.team_id, s.crm_configuration_id\nFROM stages s\nJOIN business_process_stages bps ON bps.stage_id = s.id\nWHERE bps.business_process_id = 6024\nAND s.crm_provider_id = 'contractsent';\n\nselect * from stages where id IN (16352,20612,18281,7344,16378,16309,5036,15223,14535,6293,12098,11607)","depth":4,"value":"SELECT * FROM team_features where team_id = 1;\n\nSELECT * FROM teams WHERE name LIKE '%Vixio%'; # 340,270,11922\nSELECT * FROM users WHERE team_id = 340; # 12015\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 340\nand sa.provider = 'salesforce';\n# and sa.provider = 'salesloft';\n\nselect * from crm_fields where crm_configuration_id = 270 and object_type = 'event';\n# 125558 - Event Type - Event_Type__c\n# 125552 - Event Status - Event_Status__c\n\nSELECT * FROM sidekick_settings WHERE team_id = 340;\n\nSELECT * FROM crm_field_values WHERE crm_field_id in (125552);\n\nselect * from activities where crm_configuration_id = 270\nand type = 'conference' and crm_provider_id IS NOT NULL\nand actual_start_time > '2024-09-16 09:00:00' order by scheduled_start_time;\n\nSELECT * FROM activities WHERE id = 20871677;\nSELECT * FROM crm_field_data WHERE activity_id = 20871677;\n\nselect * from crm_layouts where crm_configuration_id = 270;\nselect * from crm_layout_entities where crm_layout_id in (886,887);\n\nSELECT * FROM crm_configurations WHERE id = 270;\n\nselect * from playbooks where team_id = 340; # 1514\nselect * from groups where team_id = 340;\nSELECT * FROM crm_fields WHERE id IN (125393, 125401);\n\nselect g.name as 'team name', p.name as 'playbook name', f.label as 'activity type field' from groups g\njoin playbooks p on g.playbook_id = p.id\njoin crm_fields f on p.activity_field_id = f.id\nwhere g.team_id = 340;\n\nSELECT * FROM activities WHERE uuid_to_bin('0c180357-67d2-419e-a8c3-b832a3490770') = uuid; # 20448716\nselect * from crm_field_data where object_id = 20448716;\n\nselect * from activities where crm_configuration_id = 270 and provider = 'salesloft' order by id desc;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%CybSafe%'; # 343,273,12008\nselect * from opportunities where team_id = 343;\nselect * from opportunities where team_id = 343 and crm_provider_id = '18099102526';\nselect * from opportunities where team_id = 343 and account_id = 945217482;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 343\nand sa.provider = 'hubspot';\n\nselect * from accounts where team_id = 343 order by name asc;\n\nselect * from stages where crm_configuration_id = 273 and type = 'opportunity';\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Voyado%'; # 353,283,12143\nSELECT * FROM activities WHERE crm_configuration_id = 283 and account_id = 3777844 order by id desc;\nSELECT * FROM accounts WHERE team_id = 353 AND name LIKE '%Salesloft%';\nSELECT * FROM activities WHERE id = 20717903;\n\nselect * from participants where activity_id IN (20929172,20928605,20928468,20926272,20926271,20926270,20926269,20916499,20916454,20916436,20916435,20900015,20900014,20900013,20897312,20897243,20897241,20897237,20897232,20897229,20893648,20893231,20893230,20893229,20893228,20889784,20885039,20885038,20885037,20885036,20885035,20882728,20882708,20882703,20882702,20869828,20869811,20869806,20869801,20869799,20869798,20869796,20869795,20869794,20869761,20869760,20869759,20868688,20868687,20850340,20847195,20841710,20833967,20827021,20825307,20825305,20825297,20824615,20824400,20823927,20821760,20795588,20794233,20794057,20793710,20785811,20781789,20781394,20781307,20762651,20758453,20758282,20757323,20756643,20756636,20756629,20756627,20756606,20756605,20756604,20756603,20756602,20756600,20756599,20756598,20756595,20756594,20756589,20756587,20756577,20756573,20748918,20748386,20748385,20748384,20748383,20748382,20748381,20748380,20748379,20748377,20748375,20748373,20743301,20717905,20717904,20717903,20717901,20717899);\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 353\nand sa.provider = 'salesforce';\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%modern world business solutions%'; # 345,275,12016, l.atkinson@mwbsolutions.co.uk\nSELECT * FROM activities WHERE uuid_to_bin('3921d399-3fef-4609-a291-b0097a166d43') = uuid;\n# id: 20940638, user: 12022, contact: 5305871\nSELECT * FROM activity_summary_logs WHERE activity_id = 20940638;\nselect * from contacts where team_id = 345 and crm_provider_id = '30891432415' order by name asc; # 5305871\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 345\nand sa.provider = 'hubspot';\n\nselect * from users where team_id = 345 and id = 12022;\nSELECT * FROM crm_profiles WHERE user_id = 12022;\nSELECT * FROM participants WHERE activity_id = 20940638;\nSELECT * FROM users u\nJOIN crm_profiles cp ON u.id = cp.user_id\nWHERE u.team_id = 345;\n\nselect * from contacts where team_id = 345 and crm_provider_id = '30880813535' order by name desc; # 5305871\n\nselect * from team_features where team_id = 345;\nSELECT * FROM activities WHERE uuid_to_bin('11701e2d-2f82-4dab-a616-1db4fad238df') = uuid; # 21115197\nSELECT * FROM participants WHERE activity_id = 20897406;\n\n\n\nSELECT * FROM activities WHERE uuid_to_bin('63ba55cd-1abc-447d-83da-0137000005b7') = uuid; # 20953912\nSELECT * FROM activities WHERE crm_configuration_id = 275 and provider = 'ringcentral' and title like '%1252629100%';\n\n\nSELECT * FROM activities WHERE id = 20946641;\nSELECT * FROM crm_profiles WHERE user_id = 10211;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Lunio%'; # 120,97,10984, triger@lunio.ai\nSELECT * FROM opportunities WHERE crm_configuration_id = 97 and crm_provider_id = '006N1000006c5PpIAI';\nselect * from stages where crm_configuration_id = 97 and type = 'opportunity';\nselect * from opportunities where team_id = 120;\n\n\nselect * from crm_configurations crm join teams t on crm.id = t.crm_id\nwhere 1=1\nAND t.current_billing_plan IS NOT NULL\nAND crm.auto_sync_activity = 0\nand crm.provider = 'hubspot';\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Exclaimer%'; # 270,205,10053,james.lewendon@exclaimer.com\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 270\nand sa.provider = 'salesforce';\nSELECT * FROM activities WHERE uuid_to_bin('b54df794-2a9a-4957-8d80-09a600ead5f8') = uuid; # 21637956\nSELECT * FROM crm_profiles WHERE user_id = 11446;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Cygnetise%'; # 372,300,12554, alex.chikly@cygnetise.com\nselect * from playbooks where team_id = 372;\nselect * from crm_fields where crm_configuration_id = 300 and object_type = 'event'; # 141340\nSELECT * FROM crm_field_values WHERE crm_field_id = 141340;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 372\nand sa.provider = 'salesforce';\n\nselect * from crm_profiles where crm_configuration_id = 300;\nSELECT * FROM crm_configurations WHERE team_id = 372;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Planday%'; # 291,242,11501,mfa@planday.com\nSELECT * FROM opportunities WHERE team_id = 291 and crm_provider_id = '006bG000005DO86QAG'; # 3207756\nselect * from crm_field_data where object_id = 3207756;\nSELECT * FROM crm_fields WHERE id = 111834;\n\nselect f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value\nFROM crm_fields f\nJOIN crm_field_data fd ON f.id = fd.crm_field_id\nWHERE f.crm_configuration_id = 242\nAND f.object_type = 'opportunity'\nAND fd.object_id IN (3207756)\nORDER BY fd.object_id, fd.updated_at;\n\nSELECT * FROM crm_configurations WHERE auto_connect = 1;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Tour%'; # 187,209,8150,salesforce-admin@tourlane.com\nselect * from group_deal_risk_types drgt join groups g on drgt.group_id = g.id\nwhere g.team_id = 187;\n\nselect * from `groups` where team_id = 187;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 187\nand sa.provider = 'salesforce';\n\n# Destination - 98870 - Destination__c\n# Stage - 79014 - StageName\n# Land Arrangement - 98856 - Land_Arrangement__c\n# Flight - 98848 - Flight__c\n# Last activity date - 98812 - LastActivityDate\n# Last modified date - 98809 - LastModifiedDate\n# Last inbound mail timestamp - 99151 - Last_Inbound_Mail_Timestamp__c\n# next call - 98864 - Next_Call__c\n\nselect * from crm_fields where crm_configuration_id = 209 and object_type = 'opportunity';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 209;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 682;\n\nselect * from opportunities where team_id = 187 and name LIKE'%Muriel Sal%';\nselect * from opportunities where team_id = 187 and user_id = 9951 and is_closed = 0;\nselect * from activities where opportunity_id = 3538248;\n\nSELECT * FROM crm_profiles WHERE user_id = 8150;\n\nselect * from deal_risks where opportunity_id = 3538248;\n\nselect * from teams where crm_id IS NULL;\n\nSELECT opp.id AS opportunity_id,\n u.group_id AS group_id,\n MAX(\n CASE\n WHEN a.type IN (\"sms-inbound\", \"sms-outbound\") THEN a.created_at\n ELSE a.actual_end_time\n END) as last_date\nFROM opportunities opp\nleft join activities a on a.opportunity_id = opp.id\ninner join users u on opp.user_id = u.id\nwhere opp.user_id IN (9951)\n\nAND opp.is_closed = 0\nand a.status IN ('completed', 'received', 'delivered') OR a.status IS NULL\ngroup by opp.id;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Cybsafe%'; # 343,301,12008,polly.morphew@cybsafe.com\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 343\nand sa.provider = 'hubspot';\n\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 301;\nSELECT * FROM contacts WHERE id = 6612363;\nSELECT * FROM accounts WHERE id = 4235676;\nSELECT * FROM opportunities WHERE crm_configuration_id = 301 and crm_provider_id = 32983784868;\nselect * from opportunity_stages where opportunity_id = 4503759;\n# SELECT * FROM opportunities WHERE id = 4569937;\n\nselect * from activities where crm_configuration_id = 301;\nSELECT * FROM activities WHERE uuid_to_bin('d3b2b28b-c3d0-4c2d-8ed0-eef42855278a') = uuid; # 26330370\nSELECT * FROM participants WHERE activity_id = 26330370;\n\nSELECT * FROM teams WHERE id = 375;\nselect * from playbooks where team_id = 375;\n\nselect * from stages where crm_configuration_id = 301 and type = 'opportunity';\n\nselect * from teams;\nselect * from contact_roles;\n\nSELECT * FROM opportunities WHERE team_id = 343 and user_id = 12871 and close_date >= '2024-11-01';\n\nselect * from users u join crm_profiles cp on cp.user_id = u.id where u.team_id = 343;\n\nSELECT * FROM crm_field_data WHERE object_id = 3771706;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 343\nand sa.provider = 'hubspot';\n\nSELECT * FROM crm_fields WHERE crm_configuration_id = 301 and object_type = 'opportunity'\nand crm_provider_id LIKE \"%traffic_light%\";\nSELECT * FROM crm_field_values WHERE crm_field_id IN (144020,144048,144111,144113,144126,144481,144508,144531);\n\nSELECT fd.* FROM opportunities o\nJOIN crm_field_data fd ON o.id = fd.object_id\nWHERE o.team_id = 343\n# and o.user_id IS NOT NULL\nand fd.crm_field_id IN (144020,144048,144111,144113,144126,144481,144508,144531)\nand fd.value != ''\norder by value desc\n# group by o.id\n;\n\nSELECT * FROM opportunities WHERE id = 3769843;\n\nSELECT * FROM teams WHERE name LIKE '%Tour%'; # 187,209,8150, salesforce-admin@tourlane.com\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 209;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 682;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Funding Circle%'; # 220,177,8603,aswini.mishra@fundingcircle.com\nSELECT * FROM activities WHERE uuid_to_bin('7a40e99b-3b37-4bb1-b983-325b81801c01') = uuid; # 23139839\n\n\nSELECT * FROM opportunities WHERE id = 3855992;\n\nSELECT * FROM users WHERE name LIKE '%Angus Pollard%'; # 8988\n\nSELECT * FROM teams WHERE name LIKE '%Story Terrace%'; # 379, 307, 12894\nSELECT * FROM crm_fields WHERE crm_configuration_id = 307 and object_type != 'opportunity';\n\nselect * from contacts where team_id = 379 and name like '%bebro%'; # 5874411, crm: 77229348507\nSELECT * FROM crm_field_data WHERE object_id = 5874411;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 379\nand sa.provider = 'hubspot';\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%mentio%'; # 117, 94, 6371, nikhil.kumar@mention-me.com\nSELECT * FROM activities WHERE uuid_to_bin('82939311-1af0-4506-8546-21e8d1fdf2c1') = uuid;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Tourlane%'; # 187, 209, 8150, salesforce-admin@tourlane.com\nSELECT * FROM opportunities WHERE team_id = 187 and crm_provider_id = '006Se000008xfvNIAQ'; # 3537793\nselect * from generic_ai_prompts where subject_id = 3537793;\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Lunio%'; # 120, 97, 10984, triger@lunio.ai\nSELECT * FROM crm_configurations WHERE id = 97;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 97;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 355;\nSELECT * FROM crm_fields WHERE id = 32682;\n\nselect cfd.value, o.* from opportunities o\njoin crm_field_data cfd on o.id = cfd.object_id and cfd.crm_field_id = 32682\nwhere team_id = 120\nand cfd.value != ''\n;\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 120\nand sa.provider = 'salesforce';\n\nselect * from opportunities where team_id = 120 and crm_provider_id = '006N1000007X8MAIA0';\nSELECT * FROM crm_field_data WHERE object_id = 2313439;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE id = 410;\nSELECT * FROM teams WHERE name LIKE '%Local Business Oxford%';\nselect * from scorecards where team_id = 410;\nselect * from scorecard_rules;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Funding%'; # 220, 177, 8603, aswini.mishra@fundingcircle.com\nselect * from activities a\njoin opportunities o on a.opportunity_id = o.id\njoin users u on o.user_id = u.id\nwhere a.crm_configuration_id = 177 and a.type LIKE '%email-out%'\n# and a.actual_end_time > '2024-12-16 00:00:00'\n# and o.remotely_created_at > '2024-12-01 00:00:00'\n# and u.group_id = 1014\nand u.id = 9021\norder by a.id desc;\nSELECT * FROM opportunities WHERE id in (3981384,4017346);\nSELECT * FROM users WHERE team_id = 220 and id IN (8775, 11435);\n\nselect * from users where id = 9021;\nselect * from inboxes where user_id = 9021;\n\nselect * from inbox_emails where inbox_id = 1349 and email_date > '2024-12-18 00:00:00';\n\nselect * from email_messages where team_id = 220\nand orig_date > '2024-12-16 00:00:00' and orig_date < '2024-12-19 00:00:00'\nand subject LIKE '%Personal%'\n# and 'from' = 'credit@fundingcircle.com'\n;\n\nselect * from activities a\njoin opportunities o on a.opportunity_id = o.id\nwhere a.user_id = 9021 and a.type LIKE '%email-out%'\nand a.actual_end_time > '2024-12-18 00:00:00'\nand o.user_id IS NOT NULL\nand o.remotely_created_at > '2024-12-01 00:00:00'\norder by a.id desc;\n\nSELECT * FROM opportunities WHERE team_id = 220 and name LIKE '%Right Car move Limited%' and id = 3966852;\nselect * from activities where crm_configuration_id = 177 and type LIKE '%email%' and opportunity_id = 3966852 order by id desc;\n\nselect * from team_settings where name IN ('useCloseDate');\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Hurree%'; # 104, 81, 6175, jfarrell@hurree.co\nSELECT * FROM opportunities WHERE team_id = 104 and name = 'PropOp';\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 104\nand sa.provider = 'hubspot';\n\nselect * from crm_configurations where last_synced_at > '2025-01-19 01:00:00'\nselect * from teams where crm_id IS NULL;\n\nselect t.name as 'team', u.name as 'owner', u.email, u.phone\nfrom teams t\njoin activity_providers ap on t.id = ap.team_id\njoin users u on t.owner_id = u.id\nwhere 1=1\n and t.status = 'active'\n and ap.is_enabled = 1\n# and u.status = 1\n and ap.provider = 'ms-teams';\n\nselect * from crm_configurations where provider = 'bullhorn'; # 344\nSELECT * FROM teams WHERE id = 442; # 14293\nselect * from users where team_id = 442;\nselect * from social_accounts sa where sa.sociable_id = 14293;\nselect * from invitations where team_id = 442;\n\n# ********************************************************************************************************\nSELECT * FROM users WHERE email LIKE '%nea.liikamaa@eletive.com%'; # 14022\nSELECT * FROM teams WHERE id = 429;\nselect * from opportunities where team_id = 429 and crm_provider_id IN (16157415775, 22246219645);\nselect * from activities where opportunity_id in (4340436,4353519);\n\nselect * from transcription where activity_id IN (25630961,25381771);\nselect * from generic_ai_prompts where subject_id IN (4353519);\n\nSELECT\n a.id as activity_id,\n a.opportunity_id,\n a.type as activity_type,\n a.language,\n CONCAT(a.title, a.description) AS mail_content,\n e.from AS mail_from,\n e.to AS mail_to,\n e.subject AS mail_subject,\n e.body AS mail_body,\n p.type as prompt_type,\n p.status as prompt_status,\n p.content AS prompt_content,\n a.actual_start_time as created_at\nFROM activities a\n LEFT JOIN ai_prompts p ON a.transcription_id = p.transcription_id AND p.deleted_at IS NULL\n LEFT JOIN email_messages e ON a.id = e.activity_id\nWHERE a.actual_start_time > '2024-01-01 00:00:00'\n AND a.opportunity_id IN (4353519)\n AND a.status IN ('completed', 'received', 'delivered')\n AND a.deleted_at IS NULL\n AND a.type NOT IN ('sms-inbound', 'sms-outbound')\nORDER BY a.opportunity_id ASC, a.id ASC;\n\nSELECT * FROM users WHERE name LIKE '%George Fierstone%'; # 14293\nSELECT * FROM teams WHERE id = 442;\nSELECT * FROM crm_configurations WHERE id = 344;\nselect * from team_features where team_id = 442;\nselect * from groups where team_id = 442;\nselect * from playbooks where team_id = 442;\nselect * from playbook_categories where playbook_id = 1729;\nselect * from crm_fields where crm_configuration_id = 344 and id = 172024;\nSELECT * FROM crm_field_values WHERE crm_field_id = 172024;\nselect * from crm_layouts where crm_configuration_id = 344;\nselect * from playbook_layouts where playbook_id = 1729;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Learning%'; # 260, 221, 9444\n\nselect s.*\n# , s.sent_at, u.name, a.*\nfrom activity_summary_logs s\ninner join activities a on a.id = s.activity_id\ninner join users u on u.id = a.user_id\nwhere a.crm_configuration_id = 356\nand s.sent_at > date_sub(now(), interval 60 day)\norder by a.actual_end_time desc;\n\nselect * from activities a\n# inner join activity_summary_logs s on s.activity_id = a.id\nwhere a.crm_configuration_id = 356 and a.actual_end_time > date_sub(now(), interval 60 day)\n# and a.crm_provider_id is not null\n# and provider <> 'ringcentral'\nand status = 'completed'\norder by a.actual_end_time desc;\n\nselect * from teams order by id desc; # 17328, 32, 17830, integration-account@jiminny.com\nSELECT * FROM users;\nSELECT * FROM users where team_id = 260 and status = 1; # 201 - 150 active\nSELECT * FROM teams WHERE id = 260;\nselect * from team_settings where team_id = 260;\nselect * from crm_configurations where team_id = 260;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 356;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1184;\n\nselect * from accounts where crm_configuration_id = 221 order by id desc; # 7000\nselect * from leads where crm_configuration_id = 221 order by id desc; # 0\nselect * from contacts where crm_configuration_id = 221 order by id desc; # 200 000\nselect * from opportunities where crm_configuration_id = 221 order by id desc; # 0\nselect * from crm_profiles where crm_configuration_id = 221 order by id desc; # 23\nselect * from crm_fields where crm_configuration_id = 221;\nselect * from crm_field_values where crm_field_id = 5302 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 221 order by id desc;\nselect * from stages where crm_configuration_id = 221 order by id desc;\n\nselect * from accounts where crm_configuration_id = 356 order by id desc; # 7000\nselect * from leads where crm_configuration_id = 356 order by id desc; # 0\nselect * from contacts where crm_configuration_id = 356 order by id desc; # 200 000\nselect * from opportunities where crm_configuration_id = 356 order by id desc; # 0\nselect * from crm_profiles where crm_configuration_id = 356 order by id desc; # 23\nselect * from crm_fields where crm_configuration_id = 356;\nselect * from crm_field_values where crm_field_id = 5302 order by id desc;\nselect * from crm_layouts where crm_configuration_id = 356 order by id desc;\nselect * from stages where crm_configuration_id = 356 order by id desc;\n\nselect * from playbooks where team_id = 260 order by id desc; # 4 (2 deleted)\nselect * from groups where team_id = 260 order by id desc; # 27 groups, (2 deleted)\nselect * from playbook_layouts where playbook_id IN (1410,1409,1276,1254); # 4\nselect ce.* from calendars c\njoin users u on c.user_id = u.id\njoin calendar_events ce on c.id = ce.calendar_id\nwhere u.team_id = 260\nand (ce.start_time > '2025-02-21 00:00:00')\n;\n# calendar events 1207\n#\n\nselect * from opportunities where team_id = 260;\nSELECT * FROM crm_field_data WHERE object_id = 4696496;\n\nselect * from activities where crm_configuration_id = 356 and crm_provider_id IS NOT NULL;\nselect * from activities where crm_configuration_id IN (221) and provider NOT IN ('ms-teams', 'uploader', 'zoom-bot')\n# and type = 'conference' and status = 'scheduled' and activities.is_internal = 0\nand created_at > '2024-03-01 00:00:00'\norder by id desc; # 880 000, ringcentral, avaya\nSELECT * FROM participants WHERE activity_id = 26371744;\n\n# all activities 942 000 +\n# conference 7385 - scheduled 984 - external 343\n\nselect * from activities where id = 26321812;\nselect * from participants where activity_id = 26321812;\nselect * from participants where activity_id in (26414510,26414514,26414516,26414604,26414653,26414655);\nselect * from leads where id in (720428,689175,731546,645866,621037);\n\nselect * from users where id = 13841;\nselect * from opportunities where user_id = 9541;\nselect * from stages where id = 15900;\n\nselect * from accounts where\n# id IN (4160055,5053725,4965303,4896434)\nid in (4584518,3249934,3218025,3891133,3399450,4172999,4485161,3101785,4587203,3070816,2870343,2870341,3563940,4550846,3424464,3249963,2870342)\n;\n\nselect * from activities where id = 26654935;\nSELECT * FROM opportunities WHERE id = 4803458;\n\nSELECT * FROM opportunities where team_id = 260 and user_id = 13841 AND stage_id = 15900;\nSELECT id, uuid, provider, type, lead_id, account_id, contact_id, opportunity_id, stage_id, status, recording_state, title, actual_start_time, actual_end_time\nFROM activities WHERE user_id = 13841 AND opportunity_id IN (4729783, 4731717, 4731726, 4732064, 4732849, 4803458, 4813213);\n\nSELECT DISTINCT\n o.id, o.stage_id, s.name, a.title,\n a.*\nFROM activities a\n# INNER JOIN tracks t ON a.id = t.activity_id\nINNER JOIN users u ON a.user_id = u.id\nINNER JOIN teams team ON u.team_id = team.id\nINNER JOIN groups g ON u.group_id = g.id\nINNER JOIN opportunities o ON a.opportunity_id = o.id\nINNER JOIN stages s ON o.stage_id = s.id\nWHERE\n a.crm_configuration_id = 356\n AND a.status IN ('completed', 'failed')\n AND a.recording_state != 'stopped'\n# and a.user_id = 13841\n AND u.uuid = uuid_to_bin('6f40e4b8-c340-4059-b4ac-1728e87ea99e')\n AND team.uuid = uuid_to_bin('a607fba7-452e-4683-b2af-00d6cb52c93c')\n AND g.uuid = uuid_to_bin('b5d69e40-24a0-4c16-810b-5fa462299f94')\n\n AND a.type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n# AND t.type IN ('audio', 'video')\n AND (\n (a.actual_start_time BETWEEN '2025-03-13 00:00:00' AND '2025-03-18 07:59:59')\n OR\n (\n a.actual_start_time IS NULL\n AND a.type IN ('sms-outbound', 'sms-inbound')\n AND a.created_at BETWEEN '2025-03-13 00:00:00' AND '2025-03-18 07:59:59'\n )\n )\n AND (\n a.is_private = 0\n OR (\n a.is_private = 1\n AND u.uuid = uuid_to_bin('6f40e4b8-c340-4059-b4ac-1728e87ea99e')\n )\n )\n AND (\n# s.id = 15900\n s.uuid = uuid_to_bin('04ca1c26-c666-4268-a129-419c0acffd73')\n OR s.uuid IS NULL -- Include records without opportunity stage\n )\n\nORDER BY a.actual_end_time DESC;\n# ********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Lead Forensics%'; # 190, 162, 8474, willsc@leadforensics.com\nSELECT * FROM users WHERE team_id = 190;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 190\nand sa.provider = 'hubspot';\n\nselect * from role_user where user_id = 8474;\n\nselect * from crm_configurations where provider = 'bullhorn';\n\nSELECT * FROM opportunities WHERE uuid_to_bin('94578249-65ec-4205-90f2-7d1a7d5ab64a') = uuid;\nSELECT * FROM users WHERE uuid_to_bin('26dbadeb-926f-4150-b11b-771b9d4c2f9a') = uuid;\n\nSELECT * FROM opportunities WHERE id = 4732493;\nselect * from activities where opportunity_id = 4732493;\n\n# ********************************************************************************************************\nSELECT * FROM teams WHERE id = 443; # 358, 14315, andrea.romano@correrenaturale.com\nSELECT * FROM opportunities WHERE team_id = 443;\n\nSELECT a.id, a.type, a.user_id, a.status, a.deleted_at, u.name, u.email, u.team_id as activity_team_id, u.status, u.deleted_at, t.name, t.status, s.team_id as stage_team_id\nFROM activities AS a\nJOIN stages AS s ON a.stage_id = s.id\nJOIN users AS u ON u.id = a.user_id\nJOIN teams AS t ON t.id = s.team_id\nWHERE u.team_id <> s.team_id and t.id > 135;\n\n\nSELECT\n crm_configuration_id,\n crm_provider_id,\n COUNT(*) as duplicate_count,\n GROUP_CONCAT(id) as stage_ids,\n GROUP_CONCAT(name) as stage_names\nFROM stages\nGROUP BY crm_configuration_id, crm_provider_id\nHAVING COUNT(*) > 1\nORDER BY duplicate_count DESC;\n\nselect * from stages where id IN (14898,14907);\n\nselect * from business_processes;\n\nSELECT *\nFROM crm_configurations\nWHERE team_id IN (\n SELECT team_id\n FROM crm_configurations\n GROUP BY team_id\n HAVING COUNT(*) > 1\n)\nORDER BY team_id;\n\nSELECT *\nFROM teams\nWHERE crm_id IN (\n SELECT crm_id\n FROM teams\n GROUP BY crm_id\n HAVING COUNT(*) > 1\n)\nORDER BY crm_id;\n\n# ***************************************************************************\nselect * from crm_configurations where provider = 'integration-app';\nSELECT * FROM teams WHERE id = 443; # Correre Naturale 358 14315 andrea.romano@correrenaturale.com\nselect * from activities where crm_configuration_id = 358 order by actual_end_time desc;\nselect id, uuid, actual_end_time, crm_provider_id, is_internal, playbook_category_id, type, user_id, lead_id, contact_id, account_id, opportunity_id, status, title from activities where crm_configuration_id = 358 order by actual_end_time desc;\nselect * from team_features where team_id = 358;\nselect * from activity_summary_logs;\n\nselect * from teams where id = 406;\n\n# ************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Sportfive%'; # 267, 202, 14637, srv.salesforce@sportfive.com\nselect * from activities where crm_configuration_id = 202 order by actual_end_time desc;\n\nSELECT * FROM users where id = 14637;\nSELECT * FROM teams where id = 267;\nSELECT * FROM groups where id = 1118;\n\nselect g.name, a.title, uuid_from_bin(a.uuid), a.external_id, a.status, a.recording_state, a.recording_reason_code, a.scheduled_start_time, a.scheduled_end_time, a.actual_start_time, a.actual_end_time from activities a\ninner join users u on u.id = a.user_id\ninner join groups g on g.id = u.group_id\nwhere a.crm_configuration_id = 202\nand a.is_internal = 0\nand (a.scheduled_start_time between '2025-03-19 00:00:00' and '2025-03-21 00:00:00')\nand a.type = 'conference'\nand a.status != 'completed'\nand a.external_id is not null\norder by a.scheduled_start_time desc;\n\nSELECT * FROM activities\nWHERE crm_configuration_id = 202\n AND status IN ('completed', 'failed')\n AND recording_state != 'stopped'\n AND type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n AND (is_private = 0 OR user_id = 14637)\n AND (\n (\n actual_start_time BETWEEN '2025-03-12 12:00:00' AND '2025-03-24 11:59:59'\n ) OR (\n actual_start_time IS NULL\n AND type IN ('sms-outbound', 'sms-inbound')\n AND created_at BETWEEN '2025-03-12 12:00:00' AND '2025-03-24 11:59:59'\n )\n )\n AND NOT EXISTS (\n SELECT 1\n FROM tracks\n WHERE\n tracks.activity_id = activities.id\n AND tracks.type IN ('audio', 'video')\n )\nORDER BY actual_end_time DESC;\n\nSELECT DISTINCT\n a.*\nFROM activities a\nINNER JOIN tracks t ON a.id = t.activity_id\nINNER JOIN users u ON a.user_id = u.id\nINNER JOIN teams team ON u.team_id = team.id\nWHERE\n a.crm_configuration_id = 202\n AND a.status IN ('completed', 'failed')\n AND a.recording_state != 'stopped'\n# and a.user_id = 14637\n AND a.type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n# AND t.type IN ('audio', 'video')\n AND (\n (a.actual_start_time BETWEEN '2025-03-12 12:00:00' AND '2025-03-24 11:59:59')\n OR\n (\n a.actual_start_time IS NULL\n AND a.type IN ('sms-outbound', 'sms-inbound')\n AND a.created_at BETWEEN '2025-03-12 12:00:00' AND '2025-03-24 11:59:59'\n )\n )\n AND (\n a.is_private = 0\n OR (\n a.is_private = 1\n AND a.user_id = 14637\n )\n )\n\nORDER BY a.actual_end_time DESC\n;\n\nSELECT DISTINCT a.*\nFROM activities a\nINNER JOIN users u ON a.user_id = u.id\nINNER JOIN teams t ON u.team_id = t.id\n# INNER JOIN tracks tr ON a.id = tr.activity_id\n# INNER JOIN groups g ON u.group_id = g.id\nWHERE 1=1\n AND t.id = 267\n# AND t.uuid = uuid_to_bin('aed4927b-f1ea-499e-94c3-83762fd233e8')\n AND a.status IN ('completed', 'failed')\n AND a.recording_state != 'stopped'\n AND a.type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n# AND tr.type NOT IN ('audio', 'video')\n AND (\n a.is_private = 0\n OR a.user_id = 14637\n )\n AND (\n (a.actual_start_time BETWEEN '2025-03-19 00:00:00' AND '2025-03-21 23:59:59')\n OR (\n a.actual_start_time IS NULL\n AND a.type IN ('sms-outbound', 'sms-inbound')\n AND a.created_at BETWEEN '2025-03-19 00:00:00' AND '2025-03-21 23:59:59'\n )\n )\n# and NOT EXISTS (\n# SELECT 1\n# FROM tracks t\n# WHERE t.activity_id = a.id\n# AND t.type IN ('audio', 'video')\n# )\n\nORDER BY a.actual_end_time DESC;\n\nSELECT * FROM tracks WHERE activity_id = 26485995;\n\nselect a.is_private, a.title, uuid_from_bin(a.uuid), a.external_id, a.status, a.recording_state, a.recording_reason_code, a.scheduled_start_time, a.scheduled_end_time, a.actual_start_time, a.actual_end_time from activities a\ninner join users u on u.id = a.user_id\nwhere a.crm_configuration_id = 202\n# and a.is_internal = 0\nand (a.actual_start_time between '2025-03-19 00:00:00' and '2025-03-21 00:00:00')\nand a.type IN (\"softphone\",\"softphone-inbound\",\"conference\",\"sms-inbound\")\nand a.status IN ('completed', 'failed')\n# and a.external_id is not null\norder by a.actual_end_time desc;\n\nselect * from activities a where a.crm_configuration_id = 202\nand a.actual_start_time between '2025-03-20 00:00:00' and '2025-03-21 00:00:00'\n# AND a.type IN ('softphone', 'softphone-inbound', 'conference', 'sms-inbound', 'sms-outbound')\n\nselect g.name, a.title, uuid_from_bin(a.uuid), a.external_id, a.status, a.recording_state, a.recording_reason_code, a.scheduled_start_time, a.scheduled_end_time, a.actual_start_time, a.actual_end_time from activities a\ninner join users u on u.id = a.user_id\ninner join groups g on g.id = u.group_id\nwhere a.crm_configuration_id = 202\nand a.is_internal = 0\nand (a.scheduled_start_time between '2025-03-19 00:00:00' and '2025-03-21 00:00:00')\nand a.type = 'conference'\nand a.status != 'completed'\nand a.external_id is not null\norder by a.scheduled_start_time desc;\n\nSELECT * FROM teams WHERE name LIKE '%Tourlane%';\nSELECT * FROM crm_fields WHERE crm_configuration_id = 209 and object_type = 'opportunity';\nSELECT * FROM crm_field_data WHERE crm_field_id = 98809;\n\nselect * from users where status = 1 AND timezone = 'MDT';\n\nselect * from opportunities where id = 3769814;\nselect * from deal_risks where opportunity_id = 3769814;\n\nselect cp.* from crm_profiles cp\njoin users u on cp.user_id = u.id\njoin crm_configurations crm on cp.crm_configuration_id = crm.id\nwhere crm.provider = 'hubspot' AND u.status = 1 AND log_notes != 'none';\n\nselect * from crm_fields where id = 154575;\n\nselect * from team_features where feature = 'SUPPORTS_SYNC_MISSING_CALL_DISPOSITIONS';\nSELECT * FROM teams WHERE id = 176; # crm 148\nselect * from activities where crm_configuration_id = 148 and provider = 'hubspot' order by id desc;\n\nselect * from activity_providers where provider = 'amazon-connect';\n\nselect * from crm_fields cf\njoin crm_configurations crm on crm.id = cf.crm_configuration_id\nwhere crm.provider = 'hubspot' and cf.object_type IN ('account', 'contact');\n\n# *********************************************************************************************\nSELECT * FROM users WHERE id IN (15415, 15418);\nSELECT * FROM groups WHERE id IN (1805,1806);\nSELECT * FROM playbooks WHERE id = 1860;\nSELECT * FROM playbook_categories WHERE id = 38634;\nSELECT * FROM crm_fields WHERE id = 189962;\n\nSELECT * FROM teams WHERE name = 'Pulsar Group'; # 472, 380, 15138 raza.gilani@vuelio.com\n\nSELECT * FROM crm_profiles WHERE user_id = 15415;\nSELECT * FROM social_accounts WHERE sociable_id = 15415 and provider = 'salesforce';\n\nselect * from sidekick_settings where team_id = 472;\n\nSELECT * FROM activities WHERE uuid_to_bin('452c58c7-b87c-4fdd-953e-d7af185e9588') = uuid; # 28617536, user: 15418\nSELECT * FROM activities WHERE uuid_to_bin('399114ee-d3a8-458c-bff5-5f654658db0a') = uuid; # 28344407, user: 15415\nSELECT * FROM activities WHERE uuid_to_bin('f0aa567f-0ab1-4bbb-96aa-37dcf184676b') = uuid; # 28580288, user: 15415\nSELECT * FROM activities WHERE uuid_to_bin('50c086b1-2770-4bca-b5ae-6bac22ec426b') = uuid; # 28566069, user: 15415\n\n# *********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%TeamTailor%'; # 109, 218, 13969, salesforce-integrations@teamtailor.com\nselect * from crm_configurations where id = 218;\nSELECT * FROM activities WHERE uuid_to_bin('e39b5857-7fdb-4f5a-951a-8d3ca69bb1b0') = uuid; # 28338765\nSELECT * FROM users WHERE id IN (13232, 13230);\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 109\nand sa.provider = 'salesforce';\n\n0057R00000EPL5HQAX Inez Ekblad\n\n1091cb81-5ea1-4951-a0ed-f00b568f0140 Triman Kaur\n\nSELECT * FROM crm_profiles WHERE user_id IN (13232, 13230);\n\n############################################################################################\nSELECT * FROM activities WHERE uuid_to_bin('675eeaeb-5681-42db-90bc-54c07a604408') = uuid; # 28655939 00UVg00000FLvnSMAT\nSELECT * FROM crm_field_data WHERE activity_id = 28655939;\nSELECT * FROM crm_fields WHERE id IN (94491,94493,94498);\nSELECT * FROM users WHERE id = 13658;\nSELECT * FROM teams WHERE id = 109;\nSELECT * FROM crm_configurations WHERE id = 218;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 109\nand sa.provider = 'salesforce';\n\n# ********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Strengthscope%'; # 481, 390, 15420, katy.holden@strengthscope.comk\nSELECT * FROM stages WHERE crm_configuration_id = 390;\nselect * from business_processes where team_id = 481 and crm_configuration_id = 390;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 481\nand sa.provider = 'salesforce';\n\n\nSELECT * FROM users WHERE id = 15780; # team 462\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 462\nand sa.provider = 'hubspot';\n\n\nselect * from teams where id = 495;\nSELECT * FROM users WHERE id = 15794;\nselect * from social_accounts where sociable_id = 15794;\n\n# ********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Flight%'; # 427, 333, 13752\nSELECT * FROM accounts WHERE team_id = 427 and crm_provider_id = '668731000183444517';\n\n# ********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Group GTI%'; # 495, 407, 15794\nSELECT * FROM activities WHERE crm_configuration_id = 407\nand status = 'completed' and type = 'conference'\norder by id desc;\n\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 permission_role;\n\nselect * from activities where crm_configuration_id = 407 and status = 'completed' order by id desc;\nSELECT * FROM activities WHERE id = 29512773;\nSELECT * FROM activities WHERE id IN (29042721,28991325,29002874);\n\nSELECT al.* from activity_summary_logs al join activities a on a.id = al.activity_id\nwhere a.crm_configuration_id = 407\n# and a.id IN (29042721,28991325,29002874);\n\nSELECT * FROM users WHERE id = 15794;\nSELECT * FROM users WHERE team_id = 495;\nSELECT * FROM social_accounts WHERE sociable_id = 15794;\nSELECT * FROM opportunities WHERE team_id = 495 and name like '%OC:%';\nSELECT * FROM contacts WHERE team_id = 495;\nSELECT * FROM leads WHERE team_id = 495;\nSELECT * FROM accounts WHERE team_id = 495;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 407;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 407;\nSELECT * FROM crm_configurations WHERE id = 407;\nSELECT * FROM opportunities WHERE team_id = 495 and close_date BETWEEN '2025-06-01' AND '2025-07-01'\nand user_id IS NOT NULL and is_closed = 1 and is_won = 1;\n\n# ********************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Hamilton Court FX LLP%'; # 249, 187, 10103\nSELECT * FROM activities WHERE uuid_to_bin('4659c2bb-9a49-484e-9327-a3d66f1e028c') = uuid; # 28951064\nSELECT * FROM crm_fields WHERE crm_configuration_id = 187 and object_type IN ('tasks', 'event');\n\n# *********************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Checkstep%'; # 325, 256, 11753\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 325\nand sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('7be372e2-1916-4d79-a2f3-ca3db1346db3') = uuid; # 28611085\nSELECT * FROM activities WHERE uuid_to_bin('980f0336-840b-4185-a5a9-30cf8b0749a8') = uuid; # 28719733\nSELECT * FROM activity_summary_logs where activity_id = 28719733;\n\n# *************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Learning%'; # 260, 356, 9444\nSELECT * FROM activity_summary_logs where sent_at BETWEEN '2025-06-09 11:38:00' AND '2025-06-09 11:40:00';\nSELECT * FROM leads WHERE crm_configuration_id = 356 and crm_provider_id = '230045001502770504'; # 823630\nselect * from activities where crm_configuration_id = 356 and lead_id = 841732;\n\nSELECT * from activity_summary_logs al join activities a on a.id = al.activity_id\nwhere a.crm_configuration_id = 356;\n\nselect * from activities where crm_configuration_id = 356\nand actual_end_time between '2025-06-09 11:00:00' and '2025-06-09 12:00:00'\norder by id desc;\n\nselect * from accounts where crm_configuration_id = 356 and crm_provider_id = '230045001514403366' order by id desc;\nselect * from leads where crm_configuration_id = 356 and crm_provider_id = '230045001514275654' order by id desc;\nselect * from contacts where crm_configuration_id = 356 and crm_provider_id = '230045001514403366' order by id desc;\nselect * from opportunities where crm_configuration_id = 356 and crm_provider_id = '230045001514403366' order by id desc;\n\nselect * from team_features where team_id = 260;\nselect * from features where id IN (1,2,4,6,18,19,20,9,10,3,23,24,25,26,27);\n\nSELECT * FROM activities WHERE uuid_to_bin('7be372e2-1916-4d79-a2f3-ca3db1346db3') = uuid;\n\nselect * from crm_fields;\nselect * from crm_layout_entities;\n\nSELECT * FROM teams WHERE name LIKE '%Optable%';\n\n# *************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Teamtailor%'; # 109, 218, 13969\nSELECT * FROM crm_configurations WHERE id = 218;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 109\nand sa.provider = 'salesforce';\nSELECT * FROM activities WHERE uuid_to_bin('675eeaeb-5681-42db-90bc-54c07a604408') = uuid; # 28655939\nSELECT * FROM crm_field_data WHERE activity_id = 28655939;\nSELECT * FROM crm_fields WHERE id in (94491,94493,94498);\n\nselect * from teams where crm_id IS NULL;\n\nSELECT * FROM activities WHERE uuid_to_bin('71aa8a0c-9652-4ff6-bee7-d98ae60abef6') = uuid;\n\n# *************************************************************************************************\nselect * from team_domains where team_id = 399;\nSELECT * FROM teams WHERE name LIKE '%Rydoo%'; # 399, 318, 13207\n\nselect * from calendar_events where id = 5163781;\nSELECT * FROM activities WHERE uuid_to_bin('be2cbc52-7fda-46a0-9ae0-25d9553eafc0') = uuid; # 29443896\nSELECT * FROM participants WHERE activity_id = 29443896;\nselect * from contacts where crm_configuration_id = 318 and email = 'marianne.westeng@strawberry.no';\nselect * from leads where crm_configuration_id = 318 and email = 'marianne.westeng@strawberry.no';\n\nselect * from activities where user_id = 14937 order by created_at ;\n\nselect * from users where id = 14937;\n\nselect * from contacts where crm_configuration_id = 318 and email LIKE '%@strawberry.se';\nselect * from opportunities where crm_configuration_id = 318 and crm_provider_id = '006Sf00000D1WOAIA3';\n\nselect * from activities a join participants p on a.id = p.activity_id\nwhere crm_configuration_id = 318 and a.updated_at > '2025-06-23T08:18:43Z';\n\n# *************************************************************************************************\nSELECT * FROM opportunities WHERE team_id = 379 and crm_provider_id = '39334518886';\nSELECT * FROM opportunities WHERE team_id = 379 order by id desc;\nSELECT * FROM teams WHERE id = 379;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 379 and sociable_id = 13852\nand sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE id = 307;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 307;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1027;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 307\n and id IN (144750,144855,145158,155227);\n\nSELECT * FROM activities;\n\n\nselect * from activities\nwhere created_at > '2025-07-01 00:00:00'\n# and created_at < '2025-08-01 00:00:00'\nand type not in ('email-outbound', 'email-inbound')\nand account_id is null\nand contact_id is null\nand lead_id is null\nand opportunity_id is not null\n;\nSELECT * FROM activities WHERE id IN (25344155, 25344296, 25501909, 28692187);\nSELECT * FROM crm_configurations WHERE id in (335,301,200);\n\nselect * from crm_fields where crm_configuration_id = 230 and crm_provider_id = 'Age2__c';\n\nSELECT * FROM teams WHERE name LIKE '%Resights%';\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\n\nselect * from crm_configurations where provider = 'bullhorn'; # 344\nselect * from teams where id IN (442);\n\nselect * from activities\nwhere crm_configuration_id = 177\nand provider = 'amazon-connect'\n order by id desc;\n# and source <> 'gong';\n\nselect * from activity_providers where provider = 'amazon-connect';\n\nSELECT * FROM activities WHERE uuid_to_bin('cec1993b-a7e5-4164-b74d-d680ea51d2f2') = uuid;\n\n\nselect * from crm_configurations where store_transcript = 1;\nSELECT * FROM teams WHERE id IN (80);\n\n# *************************************************************************************************\nSELECT * FROM teams WHERE name LIKE '%Sedna%'; # 277, 213, 12594\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 277\nand sa.provider = 'salesforce';\n\nselect * from activities where crm_configuration_id = 213 and account_id = 2511502;\n\nselect * from crm_configurations where id = 213;\n\nSELECT * FROM activities WHERE uuid_to_bin('35aa790a-8569-4544-8268-66f9a4a26804') = uuid; # 33981604\nSELECT * FROM participants WHERE activity_id = 33981604;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 337 and object_type = 'task';\n\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 431\nand sa.provider = 'salesforce';\nSELECT * FROM activities WHERE uuid_to_bin('b5476c7d-19a8-491b-869d-676ea1e857b6') = uuid; # 33997223\nselect * from activity_summary_logs where activity_id = 33997223;\nselect * from activity_notes where activity_id = 33997223;\n\n# ***********************************\nSELECT * FROM teams WHERE name LIKE '%Abode%';\n\n\nselect * from features;\nselect * from teams t\nwhere t.status = 'active'\nand id NOT IN (select team_id from team_features where feature_id = 9)\n;\n\n\nselect * from playbook_layouts where playbook_id = 1725;\nSELECT * FROM activities WHERE uuid_to_bin('65cc283c-4849-49e6-927f-4c281c8fea19') = uuid; # 34297473\nselect * from teams where id = 318;\nselect * from crm_configurations where team_id = 318;\nselect * from playbooks where team_id = 318;\nSELECT * FROM crm_layouts where crm_configuration_id = 381;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1259;\nSELECT * FROM crm_fields WHERE id IN (192938,192936,192939);\n\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1266;\nSELECT * FROM crm_fields WHERE id IN (192980,192991,192997,192998,193064,193067);\n\nSELECT * FROM activities WHERE uuid_to_bin('a902289b-285c-48eb-9cc2-6ad6c5d938f5') = uuid; # 34297533\n\n\n\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 927;\nSELECT * FROM crm_fields WHERE id IN (131668,131669,131670,131671,131676,131797);\n\nSELECT * FROM teams WHERE name LIKE '%Peripass%'; # 351, 281, 12124\nselect * from crm_layouts where crm_configuration_id = 281;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 927;\nselect * from crm_fields where crm_configuration_id = 281 and id in (131668,131669,131670,131671,131676,131797);\nselect * from opportunities where crm_configuration_id = 281;\n\nSELECT * FROM activities WHERE id IN (34211315, 34130075);\nSELECT * FROM crm_field_data WHERE object_id IN (34211315, 34130075);\n\nselect cf.crm_configuration_id, cle.crm_layout_id, cle.id, cf.id from crm_field_data cfd\njoin crm_layout_entities cle on cle.id = cfd.crm_layout_entity_id\njoin crm_fields cf on cle.crm_field_id = cf.id\nwhere cf.deleted_at IS NOT NULL\nGROUP BY cle.id, cf.id;\n\nselect * from crm_layouts where id IN (355);\nselect u.email, t.crm_id, t.* from teams t\njoin users u on u.id = t.owner_id\nwhere crm_id IN (97);\n\nSELECT * FROM crm_fields WHERE id = 96492;\n\nselect * from permissions;\nselect * from permission_role where permission_id = 247;\nselect * from roles;\n\nselect * from migrations;\n# *****************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('291e3c21-11cc-4728-aee7-6e4bedf86d72') = uuid; # 34262174\nSELECT * FROM crm_configurations WHERE id = 301;\nSELECT * FROM teams WHERE id = 343;\nselect * from social_accounts sa\njoin users u on sa.sociable_id = u.id\nwhere u.team_id = 343\nand sa.provider = 'hubspot';\n\nselect * from participants where activity_id = 34262174;\n\nselect * from contacts where crm_configuration_id = 301 and id = 6976326;\nselect * from accounts where crm_configuration_id = 301 and id IN (4647626, 4815829); # 30761335403\n\nselect * from activity_summary_logs where activity_id = 34262174;\n\nselect * from users where status = 1 AND timezone = 'EST';\n\n# ****************************************************************************\nSELECT * FROM users WHERE id = 13869;\nSELECT * FROM crm_configurations WHERE id = 320;\nSELECT * FROM teams WHERE id = 401;\n\nSELECT * FROM activities WHERE uuid_to_bin('2228c16f-10be-48d5-90d4-67385219dc01') = uuid; # 29670601\n\nSELECT * FROM accounts WHERE id = 7761483;\nSELECT * FROM opportunities WHERE id = 6051814;\n\nSELECT * FROM teams WHERE name LIKE '%Seedlegals%';\n\n;select * from opportunities where updated_at > '2025-10-11' AND crm_provider_id = '34713761166';\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 177;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 577;\nSELECT * FROM crm_fields WHERE id IN (68458,68459,68480,68497,68524,68530,68554,68618,68662,68781,68810,68898,68981,69049,97467);\n\nSELECT t.id, crm.id, t.name, crm.sync_objects, crm.provider, crm.last_synced_at FROM crm_configurations crm join teams t on t.crm_id = crm.id\nwhere t.status = 'active' AND crm.provider = 'hubspot' AND crm.last_synced_at < '2025-10-22 00:00:00';\n\nSELECT * FROM activities WHERE uuid_to_bin('fa09449f-cba9-496a-b8f3-865cd3c72351') = uuid;\nSELECT * FROM crm_configurations where id = 184;\nSELECT * FROM teams WHERE id = 246;\nSELECT * FROM social_accounts WHERE sociable_id = 9259 and provider = 'hubspot';\n\nSELECT * FROM users WHERE email LIKE '%rhian.old@bud.co.uk%'; # 17700\nSELECT * FROM teams WHERE id = 551;\n\nSELECT * FROM crm_configurations WHERE id = 471;\nSELECT * FROM activities WHERE crm_configuration_id = 471 and crm_provider_id IS NOT NULL;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 471;\nSELECT * FROM crm_fields WHERE id = 307260;\nSELECT * FROM crm_field_values WHERE crm_field_id = 307260;\n\nselect * from crm_layouts where crm_configuration_id = 471;\n\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1547;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1548;\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 = 551 and sa.provider = 'hubspot';\n\nSELECT * FROM teams WHERE name LIKE '%$PCS%';\n\n# ********************************************************************************************************\nselect * from crm_configurations crm\njoin teams t on t.crm_id = crm.id\nwhere t.status = 'active'\nand crm.provider = 'hubspot';\n\n# $slug = 'HUBSPOT_WEBHOOK_SYNC';\n# $team = Jiminny\\Models\\Team::find(2);\n# $feature = Feature::query()->where('slug', $slug)->first();\n# TeamFeature::query()->create(['feature_id' => $feature->getId(),'team_id' => $team->getId()]);\n\n# hubspot_webhook_metrics\n\nselect * from crm_configurations where id = 331; # 416\nSELECT * FROM teams WHERE id = 416;\nSELECT * FROM opportunities WHERE team_id = 190;\n\nSELECT * FROM teams WHERE name LIKE '%Lead Forensics%';\nSELECT sa.id,\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 = 190 and sa.provider = 'hubspot';\n\n\n\nSELECT * FROM teams WHERE name LIKE '%Rapaport%'; # 431, 337\nSELECT * FROM teams where id = 431;\nSELECT * FROM crm_configurations where team_id = 431;\nSELECT * FROM activity_providers where team_id = 431;\nSELECT * FROM activities where crm_configuration_id = 337 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\nSELECT sa.id,\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 = 431 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%BiP%'; # 401, 320\nSELECT * FROM teams where id = 401;\nSELECT * FROM crm_configurations where team_id = 401;\nSELECT * FROM activity_providers where team_id = 401;\nSELECT * FROM activities where crm_configuration_id = 320 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\nSELECT sa.id,\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 = 401 and sa.provider = 'salesforce';\n\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 307; # 379 - Story Terrace Inc , portalId: 3921157\nSELECT * FROM contacts WHERE team_id = 379 and updated_at > '2026-01-31 11:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 379 and updated_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 379 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 379 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; # 563 - LATUS Group (ad94d501-5d09-44fd-878f-ca3a9f8865c3) , portalId: 3904501\nSELECT * FROM opportunities WHERE team_id = 563 and updated_at > '2026-02-02 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 563 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 338; # 432 - Formalize , portalId: 9214205\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 = 432 and sa.provider = 'hubspot';\nSELECT * FROM opportunities WHERE team_id = 432 and updated_at > '2026-02-02 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 432 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 436; # 519 - Moxso , portalId: 25531989\nSELECT * FROM opportunities WHERE team_id = 519 and updated_at > '2026-02-02 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 519 and updated_at > '2026-02-04 11:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 96; # 119 - Nourish Care , portalId: 26617984\nSELECT * FROM opportunities WHERE team_id = 119 and updated_at > '2026-02-02 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 119 and updated_at > '2026-02-04 11:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 331; # 416 - The National College , portalId: 7213852\nSELECT * FROM opportunities WHERE team_id = 416 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 416 and updated_at > '2026-02-04 11:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 308; # 380 - Foodles , portalId: 7723616\nSELECT * FROM opportunities WHERE team_id = 380 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 380 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 379; # 471 - imat-uve , portalId: 9177354\nSELECT * FROM opportunities WHERE team_id = 471 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 471 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 465; # 545 - Spotler , portalId: 144759271\nSELECT * FROM opportunities WHERE team_id = 545 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 545 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 455; # 537 - indevis , portalId: 25666868\nSELECT * FROM opportunities WHERE team_id = 537 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 537 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 200; # 265 - Jobadder , portalId: 6426676\nSELECT * FROM opportunities WHERE team_id = 265 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 265 and updated_at > '2026-02-06 10:30:00' order by updated_at desc;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 335; # 429 - Eletive , portalId: 6110563\nSELECT * FROM opportunities WHERE team_id = 429 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 429 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 363; # 456 - Global Group , portalId: 8901981\nSELECT * FROM opportunities WHERE team_id = 456 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 456 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 297; # 369 - Unbiased , portalId: 9229005\nSELECT * FROM opportunities WHERE team_id = 369 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 369 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 353; # 449 - Fuuse , portalId: 25781745\nSELECT * FROM opportunities WHERE team_id = 449 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 449 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 487; # 566 - Nimbus , portalId: 39982590\nSELECT * FROM opportunities WHERE team_id = 566 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 566 and updated_at > '2026-02-09 10:30:00' order by updated_at desc;\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 487;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1630;\nselect * from crm_fields where crm_configuration_id = 487 and\n(uuid_to_bin('4c6b2971-64d4-45b8-b377-427be758b5a5') = uuid or uuid_to_bin('59e368d8-65a0-4b77-b611-db37c99fbe68') = uuid);\nSELECT * FROM crm_field_values WHERE crm_field_id = 375177;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 420; # 506 - voiio , portalId: 145629154\nSELECT * FROM opportunities WHERE team_id = 506 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 506 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 479; # 558 - Momice , portalId: 535962\nSELECT * FROM opportunities WHERE team_id = 558 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 558 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 59; # 80 - Storyclash GmbH , portalId: 4268479\nSELECT * FROM opportunities WHERE team_id = 80 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 80 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 175; # 203 - Team iAM , portalId: 5534732\nSELECT * FROM opportunities WHERE team_id = 203 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 203 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 368; # 460 - OneTouch Health , portalId: 5534732183355\nSELECT * FROM opportunities WHERE team_id = 460 and updated_at > '2026-02-10 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 460 and updated_at > '2026-02-10 15:00:00' order by updated_at desc;\n\n\n\nselect * from users where id = 29643;\nSELECT * FROM crm_field_values WHERE crm_field_id = 375177;\n# ********************************************************************\nSELECT * FROM teams WHERE name LIKE '%Buynomics%'; # 462, 482, 14910\nSELECT * FROM activities WHERE crm_configuration_id = 482\nand type NOT IN ('email-inbound', 'email-outbound')\n# and description like '%The call focused on understanding Welch%'\norder 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 = 462 and sa.provider = 'salesforce';\n\nselect * from contacts where crm_configuration_id = 482 and name = 'Cyndall Hill'; # 15504749\nselect * from contacts where id = 10891096; # 482\nSELECT * FROM activities WHERE crm_configuration_id = 482\nand type NOT IN ('email-inbound', 'email-outbound')\nand contact_id = 15504749\norder by id desc;\n\nselect * from activities where id = 36793003; # 96cc7bc1-8622-4d27-92f4-baf664fc1a56, 00UOf00000PDdOXMA1\nselect * from transcription where id = 7646782;\nselect * from ai_prompts where transcription_id = 7646782;\n\n# ********************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('7a8471a3-847e-4822-802b-ddf426bbc252') = uuid; # 37370018\nSELECT * FROM activity_summary_logs WHERE activity_id = 37370018;\nSELECT * FROM teams WHERE id = 555;\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 = 555 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM activities WHERE uuid_to_bin('7c17b8aa-09df-4f85-a0f7-51f47afd712d') = uuid; # 37395250\nSELECT * FROM activities WHERE uuid_to_bin('14d60388-260d-494b-aa0d-63fdb1c78026') = uuid; # 37395250\n\nSELECT a.* FROM activities a JOIN crm_configurations c on c.id = a.crm_configuration_id\nwhere a.type IN ('softphone', 'softphone-outbound') and c.provider = 'hubspot'\nand a.provider NOT IN ('hubspot')\n# and a.provider IN ('salesloft')\n# and c.id NOT IN (70)\n# and a.duration > 30\n# and actual_start_time > '2026-02-05 00:00:00'\norder by a.id desc;\n\nSELECT * FROM activities WHERE id = 37549787;\nSELECT * FROM crm_profiles WHERE user_id = 17613;\n\nSELECT * FROM crm_configurations WHERE id = 70;\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 = 93 and sa.provider = 'hubspot';\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations WHERE id = 373; # KPSBremen.de 465 # - no social account\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 = 465 and sa.provider = 'hubspot';\n\nselect * from crm_configurations where id = 494;\n\nSELECT * FROM teams WHERE name LIKE '%splose%'; # 572, 495, 18708\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 = 572 and sa.provider = 'pipedrive';\n\nselect * from opportunities where team_id = 572\n# and name like '%Onebright%'\n# and is_closed = 1 and is_won = 0\n order by id desc;\n\n\nselect * from users where deleted_at is null and status = 2;\n\nselect * from contacts where id = 17900517;\nselect * from accounts where id = 10109838;\nselect * from opportunities where id = 6955880;\n\nselect * from opportunity_contacts where opportunity_id = 6955880;\nselect * from opportunity_contacts where contact_id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nSELECT * FROM activities WHERE uuid_to_bin('adcb8331-5988-4353-834e-383a355abba2') = uuid; # 38056424, crm 104659682404\nselect * from teams where id = 456;\nSELECT * FROM crm_configurations WHERE id = 363;\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 = 456 and sa.provider = 'hubspot';\n\nselect * from crm_layouts where crm_configuration_id = 363;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id IN (1203, 1204, 1635);\nSELECT * FROM crm_fields WHERE id IN (181536, 181538, 213455);\n\nSELECT * FROM teams WHERE name LIKE '%Electric%'; # 342, 272, 12767\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 = 342 and sa.provider = 'pipedrive';\nSELECT * FROM opportunities WHERE crm_configuration_id = 272 and name like 'NORTHUMBRIA POL%'; # and updated_at > '2025-07-01 00:00:00';\nSELECT * FROM opportunities WHERE crm_configuration_id = 272 order by remotely_created_at asc; # and updated_at > '2025-07-01 00:00:00';\nSELECT * FROM opportunities WHERE crm_configuration_id = 272 and updated_at > '2026-01-01 00:00:00';\nSELECT * FROM crm_fields WHERE crm_configuration_id = 272 and object_type = 'opportunity';\nSELECT * FROM crm_field_values WHERE crm_field_id = 127164;\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 = 342 and sa.provider = 'pipedrive';\n\nSELECT * FROM teams WHERE id = 472;\nSELECT * FROM crm_configurations WHERE id = 380;\nselect * from activities where id = 38285673; # 38285673\nSELECT * FROM users WHERE id = 16942;\nSELECT * FROM groups WHERE id = 1964;\nSELECT * FROM playbooks WHERE id = 2033;\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 499; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1678;\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\n\nSELECT * FROM activities WHERE uuid_to_bin('96b1261f-2357-49f9-ab38-23ce12008ea0') = uuid;\n\nselect * from contacts c\nwhere c.crm_configuration_id = 370 order by c.updated_at desc;\n\nSELECT * FROM participants where activity_id = 38833541;\nSELECT * FROM participants where activity_id = 39216301;\nSELECT * FROM activity_summary_logs where activity_id = 39216301;\nSELECT * FROM activities WHERE uuid_to_bin('c7d99fbe-1fb1-41f2-8f4d-52e2bf70e1e9') = uuid; # 38833541, crm 478116564181\nSELECT * FROM activities WHERE uuid_to_bin('2e6ff4d3-9faa-447a-a8c1-9acde4d885ae') = uuid; # 39216301, crm 480171536586\nselect * from crm_profiles where crm_configuration_id = 319 and crm_provider_id = 525785080;\nselect * from opportunities where crm_configuration_id = 319 and crm_provider_id = 410150124747;\nselect * from accounts where crm_configuration_id = 319 and crm_provider_id = 47150650569;\nselect * from contacts where crm_configuration_id = 319 and crm_provider_id IN ('665587441856', '742723347700');\n# owner 13236 525785080\n# contact 1 16779180 665587441856 - activity - Alex Howes alex@supportroom.com created 2026-01-26\n# contact 2 19247563 742723347700 - ash@supportroom.com 2026-03-24\n# company 4176133 47150650569\n# deal 7100953 410150124747\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 = 400 and sa.provider = 'hubspot';\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556; # owner: 18101, crm: 477\nselect * from crm_configurations where id = 477;\nSELECT * FROM users WHERE id = 18101;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'integration-app';\n\nselect * from opportunities where id = 7594349;\nselect * from opportunity_stages where opportunity_id = 7594349 order by created_at desc;\nselect * from business_processes where id = 6024;\nselect * from business_process_stages where stage_id = 16352;\nselect * from business_process_stages where business_process_id = 6024;\nselect * from stages where team_id = 459;\nselect * from teams where id = 459;\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 = 459 and sa.provider = 'hubspot';\n\nSELECT os.stage_id, s.crm_provider_id, s.name, COUNT(*) as cnt\nFROM opportunity_stages os\nJOIN stages s ON s.id = os.stage_id\nWHERE os.opportunity_id = 7594349\nGROUP BY os.stage_id, s.crm_provider_id, s.name\nORDER BY cnt DESC;\n\nSELECT s.id, s.crm_provider_id, s.name, s.team_id, s.crm_configuration_id\nFROM stages s\nJOIN business_process_stages bps ON bps.stage_id = s.id\nWHERE bps.business_process_id = 6024\nAND s.crm_provider_id = 'contractsent';\n\nselect * from stages where id IN (16352,20612,18281,7344,16378,16309,5036,15223,14535,6293,12098,11607)","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}]...
|
3098972320656733671
|
-8175836114730969756
|
idle
|
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
Show Replace Field
Search History
$stage
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
12/29
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
32
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),
]);
}
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Code changed:
Hide
Sync Changes
Hide This Notification
26
9
22
3
104
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM team_features where team_id = 1;
SELECT * FROM teams WHERE name LIKE '%Vixio%'; # 340,270,11922
SELECT * FROM users WHERE team_id = 340; # 12015
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 340
and sa.provider = 'salesforce';
# and sa.provider = 'salesloft';
select * from crm_fields where crm_configuration_id = 270 and object_type = 'event';
# 125558 - Event Type - Event_Type__c
# 125552 - Event Status - Event_Status__c
SELECT * FROM sidekick_settings WHERE team_id = 340;
SELECT * FROM crm_field_values WHERE crm_field_id in (125552);
select * from activities where crm_configuration_id = 270
and type = 'conference' and crm_provider_id IS NOT NULL
and actual_start_time > '2024-09-16 09:00:00' order by scheduled_start_time;
SELECT * FROM activities WHERE id = 20871677;
SELECT * FROM crm_field_data WHERE activity_id = 20871677;
select * from crm_layouts where crm_configuration_id = 270;
select * from crm_layout_entities where crm_layout_id in (886,887);
SELECT * FROM crm_configurations WHERE id = 270;
select * from playbooks where team_id = 340; # 1514
select * from groups where team_id = 340;
SELECT * FROM crm_fields WHERE id IN (125393, 125401);
select g.name as 'team name', p.name as 'playbook name', f.label as 'activity type field' from groups g
join playbooks p on g.playbook_id = p.id
join crm_fields f on p.activity_field_id = f.id
where g.team_id = 340;
SELECT * FROM activities WHERE uuid_to_bin('0c180357-67d2-419e-a8c3-b832a3490770') = uuid; # 20448716
select * from crm_field_data where object_id = 20448716;
select * from activities where crm_configuration_id = 270 and provider = 'salesloft' order by id desc;
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%CybSafe%'; # 343,273,12008
select * from opportunities where team_id = 343;
select * from opportunities where team_id = 343 and crm_provider_id = '18099102526';
select * from opportunities where team_id = 343 and account_id = 945217482;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 343
and sa.provider = 'hubspot';
select * from accounts where team_id = 343 order by name asc;
select * from stages where crm_configuration_id = 273 and type = 'opportunity';
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%Voyado%'; # 353,283,12143
SELECT * FROM activities WHERE crm_configuration_id = 283 and account_id = 3777844 order by id desc;
SELECT * FROM accounts WHERE team_id = 353 AND name LIKE '%Salesloft%';
SELECT * FROM activities WHERE id = 20717903;
select * from participants where activity_id IN (20929172,20928605,20928468,20926272,20926271,20926270,20926269,20916499,20916454,20916436,20916435,20900015,20900014,20900013,20897312,20897243,20897241,20897237,20897232,20897229,20893648,20893231,20893230,20893229,20893228,20889784,20885039,20885038,20885037,20885036,20885035,20882728,20882708,20882703,20882702,20869828,20869811,20869806,20869801,20869799,20869798,20869796,20869795,20869794,20869761,20869760,20869759,20868688,20868687,20850340,20847195,20841710,20833967,20827021,20825307,20825305,20825297,20824615,20824400,20823927,20821760,20795588,20794233,20794057,20793710,20785811,20781789,20781394,20781307,20762651,20758453,20758282,20757323,20756643,20756636,20756629,20756627,20756606,20756605,20756604,20756603,20756602,20756600,20756599,20756598,20756595,20756594,20756589,20756587,20756577,20756573,20748918,20748386,20748385,20748384,20748383,20748382,20748381,20748380,20748379,20748377,20748375,20748373,20743301,20717905,20717904,20717903,20717901,20717899);
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 353
and sa.provider = 'salesforce';
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%modern world business solutions%'; # 345,275,12016, [EMAIL]
SELECT * FROM activities WHERE uuid_to_bin('3921d399-3fef-4609-a291-b0097a166d43') = uuid;
# id: 20940638, user: 12022, contact: 5305871
SELECT * FROM activity_summary_logs WHERE activity_id = 20940638;
select * from contacts where team_id = 345 and crm_provider_id = '30891432415' order by name asc; # 5305871
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 345
and sa.provider = 'hubspot';
select * from users where team_id = 345 and id = 12022;
SELECT * FROM crm_profiles WHERE user_id = 12022;
SELECT * FROM participants WHERE activity_id = 20940638;
SELECT * FROM users u
JOIN crm_profiles cp ON u.id = cp.user_id
WHERE u.team_id = 345;
select * from contacts where team_id = 345 and crm_provider_id = '30880813535' order by name desc; # 5305871
select * from team_features where team_id = 345;
SELECT * FROM activities WHERE uuid_to_bin('11701e2d-2f82-4dab-a616-1db4fad238df') = uuid; # 21115197
SELECT * FROM participants WHERE activity_id = 20897406;
SELECT * FROM activities WHERE uuid_to_bin('63ba55cd-1abc-447d-83da-0137000005b7') = uuid; # 20953912
SELECT * FROM activities WHERE crm_configuration_id = 275 and provider = 'ringcentral' and title like '%1252629100%';
SELECT * FROM activities WHERE id = 20946641;
SELECT * FROM crm_profiles WHERE user_id = 10211;
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%Lunio%'; # 120,97,10984, [EMAIL]
SELECT * FROM opportunities WHERE crm_configuration_id = 97 and crm_provider_id = '006N1000006c5PpIAI';
select * from stages where crm_configuration_id = 97 and type = 'opportunity';
select * from opportunities where team_id = 120;
select * from crm_configurations crm join teams t on crm.id = t.crm_id
where 1=1
AND t.current_billing_plan IS NOT NULL
AND crm.auto_sync_activity = 0
and crm.provider = 'hubspot';
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%Exclaimer%'; # 270,205,10053,[EMAIL]
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 270
and sa.provider = 'salesforce';
SELECT * FROM activities WHERE uuid_to_bin('b54df794-2a9a-4957-8d80-09a600ead5f8') = uuid; # 21637956
SELECT * FROM crm_profiles WHERE user_id = 11446;
# [PASSWORD_DOTS]
SELECT * FROM teams WHERE name LIKE '%Cygnetise%'; # 372,300,12554, [EMAIL]
select * from playbooks where team_id = 372;
select * from crm_fields where crm_configuration_id = 300 and object_type = 'event'; # 141340
SELECT * FROM crm_field_values WHERE crm_field_id = 141340;
select * from social_accounts sa
join users u on sa.sociable_id = u.id
where u.team_id = 372
and sa.provider = 'salesforce';
select * ...
|
45341
|
|
45353
|
NULL
|
0
|
2026-04-17T09:34:31.915474+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776418471915_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles→ToolsW FirefoxFileEditViewHistoryBookmarksProfiles→ToolsWindowHelpmeet.google.com/xpx-omah-rknllian Kyuchukov (Presenting, annotating)‹$0(aolSupport Daily • in 2h 26 m100% <478 • Fri 17 Apr 12:34:31+18Help@ • Q 8• Fri17Apr 12:34.SutmAudiofileServiceTestvD 0) ServiceTraits ) 1, OcoortunitySynctra: ) 1, OooortunitySynctrar )6 resolveBusinessProcessi©, RecordManipulations TraitRunActivityAlAnalysisListener© HubspotWebhookServiceProvider|© WebhookEventProcessor© OpportunityStageUpdatedOpportunity© OpportunityPendingAlAnalysis.AfterStageChanged31 886trait OpportunitySyncTrait© DebugCommand |© JiminnyDebugCommand© KernelOpportunitySyncTrait© CrmEntityRepository9302us7s 8uaas Novakprivate function resolveBusinessProcess(?string Spipelineld): ?BusinessProcess|ar topapelaneso aes nutt) 1lwens nut1f (isset(Sthis-›cachedBusinessProcesses[Spipelineld])) {|return Sthis-›cachedßusinessProcesses[Spipelineid):SbusinessProcess • Sthis-# tBusinessProcess (Spipelineld):1f (l SbusinessProcess, instanceof BusinessProcess) €|Sthis->AngortStasbO:zovsanessrrocess ponas5ecesoary1f (1 SbusinessProcess instanceof BusinessProcess) €Sthis->Logger->info("Lnubspot) beat as not accacneo to a papetanea"pipeline' »> Spipelineld)pmpoesocoovsanosrrost colootne owamearrochoonreturn SbusinessProcess; |C4C0VS 0033/009 33008891030Sm0CSaUeareturn Sthis-›craEntityRepository->findBusinessProcessesByExternalId(Sthis-›config, Spipelineld):LJiminnylServices|Crm|Hubspor|ServiceTraits > OpportunitySyncTrait › resolveBusinessProcessDWV Windsuri Teams (Pre-Reiease) SymionyC 4 spacesAmaster o VNORMALPS172024-11_6.56.pngllian KyuchukovNikolay NikolovVasil VasilevMihail MihaylovLukas Kovalik12:34 PM | Daily - Processing...
|
NULL
|
-1323256038246261781
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles→ToolsW FirefoxFileEditViewHistoryBookmarksProfiles→ToolsWindowHelpmeet.google.com/xpx-omah-rknllian Kyuchukov (Presenting, annotating)‹$0(aolSupport Daily • in 2h 26 m100% <478 • Fri 17 Apr 12:34:31+18Help@ • Q 8• Fri17Apr 12:34.SutmAudiofileServiceTestvD 0) ServiceTraits ) 1, OcoortunitySynctra: ) 1, OooortunitySynctrar )6 resolveBusinessProcessi©, RecordManipulations TraitRunActivityAlAnalysisListener© HubspotWebhookServiceProvider|© WebhookEventProcessor© OpportunityStageUpdatedOpportunity© OpportunityPendingAlAnalysis.AfterStageChanged31 886trait OpportunitySyncTrait© DebugCommand |© JiminnyDebugCommand© KernelOpportunitySyncTrait© CrmEntityRepository9302us7s 8uaas Novakprivate function resolveBusinessProcess(?string Spipelineld): ?BusinessProcess|ar topapelaneso aes nutt) 1lwens nut1f (isset(Sthis-›cachedBusinessProcesses[Spipelineld])) {|return Sthis-›cachedßusinessProcesses[Spipelineid):SbusinessProcess • Sthis-# tBusinessProcess (Spipelineld):1f (l SbusinessProcess, instanceof BusinessProcess) €|Sthis->AngortStasbO:zovsanessrrocess ponas5ecesoary1f (1 SbusinessProcess instanceof BusinessProcess) €Sthis->Logger->info("Lnubspot) beat as not accacneo to a papetanea"pipeline' »> Spipelineld)pmpoesocoovsanosrrost colootne owamearrochoonreturn SbusinessProcess; |C4C0VS 0033/009 33008891030Sm0CSaUeareturn Sthis-›craEntityRepository->findBusinessProcessesByExternalId(Sthis-›config, Spipelineld):LJiminnylServices|Crm|Hubspor|ServiceTraits > OpportunitySyncTrait › resolveBusinessProcessDWV Windsuri Teams (Pre-Reiease) SymionyC 4 spacesAmaster o VNORMALPS172024-11_6.56.pngllian KyuchukovNikolay NikolovVasil VasilevMihail MihaylovLukas Kovalik12:34 PM | Daily - Processing...
|
45352
|
|
45432
|
NULL
|
0
|
2026-04-17T09:39:30.926731+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776418770926_m2.jpg...
|
PhpStorm
|
faVsco.js – OpportunitySyncTrait.php
|
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
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...
|
[{"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.14960937,"height":0.022222223},"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.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.17777778,"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.17708333,"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.17708333,"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.17708333,"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.17708333,"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.17708333,"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.17708333,"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.17638889,"width":0.030078124,"height":0.015277778},"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.29179686,"top":0.17569445,"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.17569445,"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.17569445,"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.17569445,"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.44960937,"top":0.17569445,"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}]...
|
-9152591048099862832
|
-4512250007517006014
|
idle
|
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
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
PhpStormFileFditViewNavigateCodelaravelRetactonToolsWindowHelpFV faVsco.s v#11894 on JY-18909-automated-reports-ask-iminny kProject v> _ Redisv _ ServiceTraitsT OpportunitySyT SyncermEntitiT SyncFieldsTraT Writeermiirait→IUTIS•Weohook(C) BatchSvncCollect(C) BatchSvncRedisS(c) Client.php© ClosedDealstageDealFieldsService(©) DecorateActivity.©) FieldDefinitions.p(C) FieldTypeConveriJ HubspotClientinte© Hubspotl okenMa© PayloadBullder.pr(c) Remorecrmobiec(C) ResponseNormali© Service.php© SyncFieldAction.r(©) SyncRelatedActiv(C) WebhookSyncBalv D IntegrationApp>D Accessorsv JAp© ActionUrl.php0p Enumurlintent@ Flowurl.php© PageResult.phe Proxyun.php(c) recuestsuloeLa kequestcxecu(D RequestExecu(C) SystemEventsE) SystemUrl.phpG TokenBuilder.0n TiokenBuilderr© Urbuilder.php> @ Config> MFilters>MJobs› _ ProspectSearchS› ServiceTraits(©) DataClient.phpC DecorateActivity.(©) LocalSearch.phpJ LocalSearchinteri© RemoteSearch.pr© service.php_ ListenersM Metadata• MigrationPipedrive_ Salesforce_ Traits(C AutomatedReportsService.phpC) SendReportJob.phpC SendReportMailJob.php(©) ReportController.phpTokenBuilder.phpc leamsetuocontroller.onopnp apl.ono1 Filesystem.pnpC Team.phpc CrealenelaAcuiviyevent.onpe) Track?rovidernstalled-vent.ono(e) RequestGenerateRenort.ob.onoOpportunitySyncurait.phpOpportunity.phpT InteractsWithPivotTable.phpC OpportunityStageUpdated.phpcventservicerrovider.ongC OpportunityPendingAiAnalysisAfterStageChanged.phpC RunOpportunityAiAnalysis.phpProcessaiAulomalionAnalysiskesults.onomoortonnortunitvBarch.onmwImportBatchJobTrait.phpe) Service.ontcacneastacesCc WTIP:trait OpportunitySyncTrait838%9.48948738708978988999009019029039049059069087077107149129139149159169189199209219229239249259269224928929750Y517049339349359369577571private function buildOpportunityData@St astade *$datal'stage id'] = $stage->id:if ($businessProcess)SrecordType = Sthis->crmEntityRepository->qetBusinessProcessRecordType$businessProcess):if SrecordType)$datal'record_type_id'] = $recordType->id;return soata.usadesprivate function resolveBusinessProcessCstrina Spipelineld): ?BusinessProcessif Spipelineld === nulb) ‹return null:ScacheKey =if (isset($this->cachedBusinessProcesses[$pipelineldl)) {rerurnsums->cacneobusnessprocesses.soloeuneloa$businessProcess = $this->getBusinessProcess(Spipelineld):if ( SbusinessProcess instanceof BusinessProcess ^$this->importStagesO:sbusinessProcess = sthis->getBus1nessProcess(sp1pelineld)*if (! $businessProcess instanceof BusinessProcess) {puhls-> logger->intol"[HubSpotl Deal is not attached to a pipeline,'pipeline' => Spipelineldisthis->cachedBus1nessProcesseslsp1pel1neld = sbusinessProcess:return sbusinessProcess2 usagesprivate tunccion gecbustnessrrocess scrine eolpetlnerd). Ebustnessrrocess= custom.lo9= laravel.logL SF (iminny@localhostU scratch_1.isonV connect.vueV Onboard.vueHs local liminnyalocalnostiL console [EU] X© CrmEntityRepository.phpnnn crm confiourations IFUlie console [PROD157115/2157310/01577-[CREDIT_CARD]= 158415831586- 15881589159015911592159315941595159615991600160116031606160)16081o0%1010161116121613161416151616161)-16181619& console [STAGING)X:Auto vPlayground vMa liminnv v026 A9 A22 V3 V104 ^ V,1d FROM social_accounts saon U.1d = sa.soc1able_10I.ns->l: on t.1d = U.team_1d10= 400and sa.provider = "nupspormllreaturesm team_features where feature id = 40:•m teams where id = 556; # owner: 18101, crm: 477m crm_configurations where id = 477:IM USerS WHERE 1d = 181011I.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,1o rkum social accounus salonuri = sa,sociaole 101..n<->1: on t.id = u.team idLid = 556 and sa.provider = 'integration-app';•m opportunities where 1d = 7594349:m opportunity_stages where opportunity_1d = 7594349 order by created_at dem business_processes where 1d = 6024m business_process_stages where stage_1d = 16352;m business_process_suages wnere dusiness_process_1d = 6024,n stages where team_1d = 457;n teams where 1d = 4571I.id, CASE WHEN v.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id.id FROM social_accounts saon u.id = sa.sociable_idl..n<->I: on t.1d = U.team_1dL1d = 459 and sa.provider = 'hubspot'*age_1d, s.crm_provider_1d, s.name, coonl(*) as chuinity_suages oss .→>l: UN S.1d = os.stage_idortunity id = 7594349Stace 1a, s.crmorovider 10, s.nameUesb.s.crm provider id, s.name, s.team_ id, s.crm confiquration ids_process_stages bps 1<->1..n: ON bps.stage_1d = s.1disiness_process_1d = 6024'oVider_1a = conthacusent" :m stages where id IN (16352,20612,18281,7344, 16378,16309,5036,15223,14535гloblSupport Daily • in 2h 21 m100% [45• Fri 17 Apr 12:39:30AutomatedRenortsCommandTestCascadeAutomated Report RetDebugging OpportunitJust one record"id": 16352,ider_id": "contractsent""Commerclals"leam l0". 40g"crm_configuration id": 3671 Where do we look for stage by crm provider id only without team of configUnly one stage wh contractsent belongs to business process 0024 — so the pivotis clean. Ineoscillation between 12 difterent stages with the same crm_provider_id must be coming trom somewherethat looks up stages without scoping to the business process.e. Wnere. crm provider and pertormea o otner cuerlesFound It. Line 459;• Service.php:459-461$stage = $this->confiq→>stages(→>update0rCreate0"crm provider id'→> $sl'id'],1, 1lniss the root causeimportStages() does update0rCreate keyed only on crm provider id withno team id or typeconstalnt in tne lookue key. unis means.When Hubspot has a pipeline with dealstage = "contractsent" and importstages() runs, it searches foiany existing stage with crm provider id = 'contractsent' under $this->config->stages() .sthis→>cont10→>stages scooesco crm contiquration 1d = 36/ — out there are 12 stages acrossdifterent team_id/contigs all with crm_provider_id = 'contractsent". The updateOrcreate finds the firstone that matches ane updates lt, potentlally changing its cean ld, name, elc. — or creales a new one.lnen soustnessProcess-›stageso→>syncstages auacnes al sace Ds rerurnec Trom tnarloo. 1obusiness process 6024. lf update0rCreate returns different stage IDs on different runs (because thelookue matches a alverent existine row, tne syne oivot eets rewrken wiin alerentibsThe result: on subsequent reso lvestage() calls, businessProcess->stages()->where('crm_provider_id','contractsent')->first() finds whichever stage was most recently synced into the pivot — a different IDeach time imporcstages runs.The tix neededInportscaces updareurcreare must include crn contlouracion ld in the seareh key.• Service.php:459-468$stage = $this->config->stages()->update0rCreate(['crm provider id' => $sl'id'],1, 1Should be:U php$stage = $this->config->stages()->update0rCreate(['crm_ provider id'→ $sl'id'],'crm confiquration id' = $this→>confiq->qetIdo.J,lThis ensures update0rCreate never matches a stage belonging to a different config/team. The lookupbecomes specific enough that it will always tind or create the correct unique stage for this contig.Oal *Ask anything (&+L)+ <> Code Claude Sonnet 4.6WWinasun leams913.21uir-oCa 4 spaces...
|
45426
|
|
45434
|
NULL
|
0
|
2026-04-17T09:39:38.145892+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776418778145_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
+FirefoxToolslaln]18FileEditViewHistoryBookmarksPr +FirefoxToolslaln]18FileEditViewHistoryBookmarksProfilesWindowHelp→Q.meet.google.com/xpx-omah-rknllian Kyuchukov (Presenting, annotating)HeipJY-13296: Retry AJJ when some questions fal v.U SubmitAudioFileServiceTest v D 01OooortunitySynctrar ) 6 oetbusinessProcessCachekey ) ) oioelneld) HubspotWebhookServiceProvider© WebhookEventProcessor|© OpportunityStageUpdated© Opportunity|© Upportunity Peno no ManaryssArters%agcchangoo© JiminnyDebugCommand|31 911trait OpportunitySyncTraitprivate function resolveßusinessProcess(?string Spipelineld): ?BusinessProcesscisset(sthis->cachedbusinessProcesses.soroetine/direturn Sthis-›cachedBusinessProcesses[Spipelineld):© Kernel|Seportunity SymeTral© CrmEntityRepository915916SbusinessProcess • Sthis->getBusinessProcess(Spipelineid):1f (1 SbusinessProcess instanceof BusinessProcess) <|Sthis->AnRoctS*ASRs():SbusinessProcess = Sthis->getBusinessProcess(Spipelineid):|if (1 SbusinessProcess instanceof BusinessProcess) (|Sthis->logger->info('[HubSpot] Deal is not attached to a pipeline','pipeline' »> Spipelineld)932933Sthis-›cachedBusinessProcesses[Sthis-›getBulsinessProcessCachekey() = SbussnessProcessretura youstnesstrocessh400780LA40N0EXprivate function getBusinessProcess(string Spipelineld): ?BusinessProcess938• Support Daily • in 2h 21 m100% C478 • Fri 17 Apr 12:39:37@ ? Q 8• Fri17 Apr 12:39.43 438 X2X19llian KyuchukovNikolay Nikolov3 GVasil VasilevMihail Mihaylovprivate function getBusinessProcessCachekey(string Spipelineid, BusinessProcess SbusinessProcess): string|return SbusinessProcess->getCrnConfiguration()-›getId) .Spipelineld;|Jiminny|Services|Crm|Hubspot|ServiceTraits > OpportunitySyncTrait • getBusinessProcessCacheKey0Vy Windsurf Teams (Pre-Release)1718942:57|UTF-8Co 4 spaces$p fix-cache-for-business-processes2024-11_6.56.pngLukas Kovalik12:39 PM | Daily - Processing...
|
NULL
|
8100476119282036946
|
NULL
|
visual_change
|
ocr
|
NULL
|
+FirefoxToolslaln]18FileEditViewHistoryBookmarksPr +FirefoxToolslaln]18FileEditViewHistoryBookmarksProfilesWindowHelp→Q.meet.google.com/xpx-omah-rknllian Kyuchukov (Presenting, annotating)HeipJY-13296: Retry AJJ when some questions fal v.U SubmitAudioFileServiceTest v D 01OooortunitySynctrar ) 6 oetbusinessProcessCachekey ) ) oioelneld) HubspotWebhookServiceProvider© WebhookEventProcessor|© OpportunityStageUpdated© Opportunity|© Upportunity Peno no ManaryssArters%agcchangoo© JiminnyDebugCommand|31 911trait OpportunitySyncTraitprivate function resolveßusinessProcess(?string Spipelineld): ?BusinessProcesscisset(sthis->cachedbusinessProcesses.soroetine/direturn Sthis-›cachedBusinessProcesses[Spipelineld):© Kernel|Seportunity SymeTral© CrmEntityRepository915916SbusinessProcess • Sthis->getBusinessProcess(Spipelineid):1f (1 SbusinessProcess instanceof BusinessProcess) <|Sthis->AnRoctS*ASRs():SbusinessProcess = Sthis->getBusinessProcess(Spipelineid):|if (1 SbusinessProcess instanceof BusinessProcess) (|Sthis->logger->info('[HubSpot] Deal is not attached to a pipeline','pipeline' »> Spipelineld)932933Sthis-›cachedBusinessProcesses[Sthis-›getBulsinessProcessCachekey() = SbussnessProcessretura youstnesstrocessh400780LA40N0EXprivate function getBusinessProcess(string Spipelineld): ?BusinessProcess938• Support Daily • in 2h 21 m100% C478 • Fri 17 Apr 12:39:37@ ? Q 8• Fri17 Apr 12:39.43 438 X2X19llian KyuchukovNikolay Nikolov3 GVasil VasilevMihail Mihaylovprivate function getBusinessProcessCachekey(string Spipelineid, BusinessProcess SbusinessProcess): string|return SbusinessProcess->getCrnConfiguration()-›getId) .Spipelineld;|Jiminny|Services|Crm|Hubspot|ServiceTraits > OpportunitySyncTrait • getBusinessProcessCacheKey0Vy Windsurf Teams (Pre-Release)1718942:57|UTF-8Co 4 spaces$p fix-cache-for-business-processes2024-11_6.56.pngLukas Kovalik12:39 PM | Daily - Processing...
|
NULL
|
|
45499
|
NULL
|
0
|
2026-04-17T09:44:42.870420+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776419082870_m1.jpg...
|
Firefox
|
Meet - Daily - Processing — Work
|
True
|
meet.google.com/xpx-omah-rkn
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Meet - Daily - Processing
Close tab
New Tab
Open G Meet - Daily - Processing
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Ilian Kyuchukov (Presenting, annotating)
Ilian Kyuchukov (Presenting, annotating)
People
6
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 Ilian Kyuchukov's presentation from your main screen
You can't unmute someone else's presentation
More options for Ilian Kyuchukov
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 Ilian Kyuchukov to your main screen
Mute Ilian Kyuchukov's microphone
More options for Ilian Kyuchukov
Ilian Kyuchukov
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....
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Meet - Daily - Processing","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":"Ilian Kyuchukov (Presenting, annotating)","depth":12,"bounds":{"left":0.07534722,"top":0.101111114,"width":0.18055555,"height":0.022222223},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Ilian Kyuchukov (Presenting, annotating)","depth":13,"bounds":{"left":0.07534722,"top":0.10222222,"width":0.18055555,"height":0.020555556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"People","depth":15,"bounds":{"left":0.88680553,"top":0.08944444,"width":0.04097222,"height":0.04},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"6","depth":22,"bounds":{"left":0.9145833,"top":0.101111114,"width":0.0048611113,"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.62833333,"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.6427778,"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.6388889,"width":0.11076389,"height":0.05666667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Unpin Ilian Kyuchukov's presentation from your main screen","depth":13,"bounds":{"left":0.346875,"top":0.5083333,"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.37465277,"top":0.5061111,"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 Ilian Kyuchukov","depth":13,"bounds":{"left":0.40520832,"top":0.5083333,"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.6315972,"top":0.83111113,"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.6649306,"top":0.83111113,"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.6982639,"top":0.83111113,"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.7861111,"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.9302083,"top":0.2911111,"width":0.069791675,"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.9079861,"top":0.28666666,"width":0.092013896,"height":0.045},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Pin Ilian Kyuchukov to your main screen","depth":13,"bounds":{"left":0.76180553,"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 Ilian Kyuchukov's microphone","depth":13,"bounds":{"left":0.7895833,"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 Ilian Kyuchukov","depth":13,"bounds":{"left":0.8201389,"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":"Ilian Kyuchukov","depth":17,"bounds":{"left":0.75451386,"top":0.36277777,"width":0.07986111,"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"}]...
|
-3689064555110888397
|
-6416532991541265644
|
click
|
accessibility
|
NULL
|
Meet - Daily - Processing
Close tab
New Tab
Open G Meet - Daily - Processing
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Ilian Kyuchukov (Presenting, annotating)
Ilian Kyuchukov (Presenting, annotating)
People
6
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 Ilian Kyuchukov's presentation from your main screen
You can't unmute someone else's presentation
More options for Ilian Kyuchukov
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 Ilian Kyuchukov to your main screen
Mute Ilian Kyuchukov's microphone
More options for Ilian Kyuchukov
Ilian Kyuchukov
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....
|
45498
|
|
45500
|
NULL
|
0
|
2026-04-17T09:44:42.870424+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776419082870_m2.jpg...
|
Firefox
|
Meet - Daily - Processing — Work
|
True
|
meet.google.com/xpx-omah-rkn
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Meet - Daily - Processing
Close tab
New Tab
Open G Meet - Daily - Processing
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Ilian Kyuchukov (Presenting, annotating)
Ilian Kyuchukov (Presenting, annotating)
People
6
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 Ilian Kyuchukov's presentation from your main screen
You can't unmute someone else's presentation
More options for Ilian Kyuchukov
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 Ilian Kyuchukov to your main screen
Mute Ilian Kyuchukov's microphone
More options for Ilian Kyuchukov
Ilian Kyuchukov
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...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Meet - Daily - Processing","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.018945312,"height":-0.045138836},"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.23398438,"top":1.0,"width":0.005859375,"height":-0.045138836},"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.23652343,"top":1.0,"width":0.0125,"height":-0.07500005},"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":"Ilian Kyuchukov (Presenting, annotating)","depth":12,"bounds":{"left":0.27558595,"top":1.0,"width":0.1015625,"height":-0.063194394},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Ilian Kyuchukov (Presenting, annotating)","depth":13,"bounds":{"left":0.27558595,"top":1.0,"width":0.1015625,"height":-0.06388891},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"People","depth":15,"bounds":{"left":0.7320312,"top":1.0,"width":0.023046875,"height":-0.05590272},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"6","depth":22,"bounds":{"left":0.7476562,"top":1.0,"width":0.002734375,"height":-0.063194394},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Take notes with Gemini","depth":14,"bounds":{"left":0.75820315,"top":1.0,"width":0.0140625,"height":-0.05590272},"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.7597656,"top":1.0,"width":0.051171876,"height":-0.063194394},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Gemini","depth":22,"bounds":{"left":0.7769531,"top":1.0,"width":0.015820313,"height":-0.063194394},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Gemini","depth":21,"bounds":{"left":0.7757813,"top":1.0,"width":0.01328125,"height":-0.056597233},"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 Ilian Kyuchukov'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 Ilian Kyuchukov","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 Ilian Kyuchukov 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 Ilian Kyuchukov'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 Ilian Kyuchukov","depth":13,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Ilian Kyuchukov","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}]...
|
-6114064858009506275
|
-6434547390050747644
|
click
|
accessibility
|
NULL
|
Meet - Daily - Processing
Close tab
New Tab
Open G Meet - Daily - Processing
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Ilian Kyuchukov (Presenting, annotating)
Ilian Kyuchukov (Presenting, annotating)
People
6
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 Ilian Kyuchukov's presentation from your main screen
You can't unmute someone else's presentation
More options for Ilian Kyuchukov
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 Ilian Kyuchukov to your main screen
Mute Ilian Kyuchukov's microphone
More options for Ilian Kyuchukov
Ilian Kyuchukov
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...
|
NULL
|
|
45564
|
NULL
|
0
|
2026-04-17T09:49:43.053837+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776419383053_m2.jpg...
|
Firefox
|
Meet - Daily - Processing — Work
|
True
|
meet.google.com/xpx-omah-rkn
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Meet - Daily - Processing
Close tab
New Tab
Open G Meet - Daily - Processing
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Ilian Kyuchukov (Presenting, annotating)
Ilian Kyuchukov (Presenting, annotating)
People
6
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.
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.
Ilian Kyuchukov
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.
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.
Vasil Vasilev
Mihail Mihaylov
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.
Lukas Kovalik
Others might see more of your background. Click to view your full video.
12:49
PM
Daily - Processing
Daily - Processing
Audio settings
Turn off microphone
Video settings
Turn off camera
Ilian Kyuchukov 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 - Processing","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.018945312,"height":-0.045138836},"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.23398438,"top":1.0,"width":0.005859375,"height":-0.045138836},"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.23652343,"top":1.0,"width":0.0125,"height":-0.07500005},"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":"Ilian Kyuchukov (Presenting, annotating)","depth":12,"bounds":{"left":0.27558595,"top":1.0,"width":0.1015625,"height":-0.063194394},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Ilian Kyuchukov (Presenting, annotating)","depth":13,"bounds":{"left":0.27558595,"top":1.0,"width":0.1015625,"height":-0.06388891},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"People","depth":15,"bounds":{"left":0.7320312,"top":1.0,"width":0.023046875,"height":-0.05590272},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"6","depth":22,"bounds":{"left":0.7476562,"top":1.0,"width":0.002734375,"height":-0.063194394},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Take notes with Gemini","depth":14,"bounds":{"left":0.75820315,"top":1.0,"width":0.0140625,"height":-0.05590272},"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.7597656,"top":1.0,"width":0.051171876,"height":-0.063194394},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Gemini","depth":22,"bounds":{"left":0.7769531,"top":1.0,"width":0.015820313,"height":-0.063194394},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Gemini","depth":21,"bounds":{"left":0.7757813,"top":1.0,"width":0.01328125,"height":-0.056597233},"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":"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":"AXStaticText","text":"Ilian Kyuchukov","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":"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":"AXStaticText","text":"Vasil Vasilev","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Mihail Mihaylov","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":"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":"12:49","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PM","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Daily - Processing","depth":12,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Daily - Processing","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Audio settings","depth":14,"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":14,"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":"Ilian Kyuchukov 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"}]...
|
7721279155848702429
|
-6425889835512038604
|
idle
|
accessibility
|
NULL
|
Meet - Daily - Processing
Close tab
New Tab
Open G Meet - Daily - Processing
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Ilian Kyuchukov (Presenting, annotating)
Ilian Kyuchukov (Presenting, annotating)
People
6
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.
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.
Ilian Kyuchukov
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.
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.
Vasil Vasilev
Mihail Mihaylov
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.
Lukas Kovalik
Others might see more of your background. Click to view your full video.
12:49
PM
Daily - Processing
Daily - Processing
Audio settings
Turn off microphone
Video settings
Turn off camera
Ilian Kyuchukov 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
|
|
45565
|
NULL
|
0
|
2026-04-17T09:50:09.949848+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776419409949_m1.jpg...
|
Firefox
|
Meet - Daily - Processing — Work
|
True
|
meet.google.com/xpx-omah-rkn
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Meet - Daily - Processing
Close tab
New Tab
Open G Meet - Daily - Processing
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Ilian Kyuchukov (Presenting, annotating)
Ilian Kyuchukov (Presenting, annotating)
People
6
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.
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.
Ilian Kyuchukov
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.
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.
Vasil Vasilev
Mihail Mihaylov
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.
Lukas Kovalik
Others might see more of your background. Click to view your full video.
12:50
PM
Daily - Processing
Daily - Processing
Audio settings
Turn off microphone
Video settings
Turn off camera
Ilian Kyuchukov 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 - Processing","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":"Ilian Kyuchukov (Presenting, annotating)","depth":12,"bounds":{"left":0.07534722,"top":0.101111114,"width":0.18055555,"height":0.022222223},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Ilian Kyuchukov (Presenting, annotating)","depth":13,"bounds":{"left":0.07534722,"top":0.10222222,"width":0.18055555,"height":0.020555556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"People","depth":15,"bounds":{"left":0.88680553,"top":0.08944444,"width":0.04097222,"height":0.04},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"6","depth":22,"bounds":{"left":0.9145833,"top":0.101111114,"width":0.0048611113,"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.62833333,"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.6427778,"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.6388889,"width":0.11076389,"height":0.05666667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Zoom in","depth":13,"bounds":{"left":0.6315972,"top":0.83111113,"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.6649306,"top":0.83111113,"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.6982639,"top":0.83111113,"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.7861111,"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.9302083,"top":0.2911111,"width":0.069791675,"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.9079861,"top":0.28666666,"width":0.092013896,"height":0.045},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Ilian Kyuchukov","depth":17,"bounds":{"left":0.75451386,"top":0.36277777,"width":0.07986111,"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":"AXStaticText","text":"Nikolay Nikolov","depth":17,"bounds":{"left":0.8802083,"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.7861111,"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.9302083,"top":0.54888886,"width":0.069791675,"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.9079861,"top":0.54444444,"width":0.092013896,"height":0.045},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":17,"bounds":{"left":0.75451386,"top":0.6205556,"width":0.061805554,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Mihail Mihaylov","depth":17,"bounds":{"left":0.8802083,"top":0.6205556,"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.73888886,"top":0.7916667,"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.665625,"top":0.8066667,"width":0.07569444,"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.64618057,"top":0.80222225,"width":0.11736111,"height":0.045},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":17,"bounds":{"left":0.75381947,"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":"12:50","depth":12,"bounds":{"left":0.050347224,"top":0.9444444,"width":0.027777778,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PM","depth":12,"bounds":{"left":0.081597224,"top":0.9444444,"width":0.016666668,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Daily - Processing","depth":12,"bounds":{"left":0.115625,"top":0.9111111,"width":0.093055554,"height":0.08888888},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Daily - Processing","depth":15,"bounds":{"left":0.115625,"top":0.9444444,"width":0.093055554,"height":0.022777777},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Audio settings","depth":14,"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":14,"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":"Ilian Kyuchukov 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"}]...
|
2308635439156572871
|
-6425889835243078860
|
idle
|
accessibility
|
NULL
|
Meet - Daily - Processing
Close tab
New Tab
Open G Meet - Daily - Processing
Close tab
New Tab
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Customize sidebar
Ilian Kyuchukov (Presenting, annotating)
Ilian Kyuchukov (Presenting, annotating)
People
6
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.
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.
Ilian Kyuchukov
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.
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.
Vasil Vasilev
Mihail Mihaylov
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.
Lukas Kovalik
Others might see more of your background. Click to view your full video.
12:50
PM
Daily - Processing
Daily - Processing
Audio settings
Turn off microphone
Video settings
Turn off camera
Ilian Kyuchukov 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....
|
45563
|
|
45662
|
NULL
|
0
|
2026-04-17T09:55:18.799103+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776419718799_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2ShellEdit|ViewSessionScriptsProfilesWindowHe iTerm2ShellEdit|ViewSessionScriptsProfilesWindowHelpec2-user@ip-10-20-6-111:~X4Ia6|•95Support Daily - in 2h 5 m100% <978Fri 17 Apr 12:55:181₴81ec2-user@ip-10-20-...88DOCKER• ₴1DEV (-zsh)882APP (-zsh)|-zsh* Review screenp...• X6ec2-user@ip-10-30-...$7Days Active: 3/3Daily Average: 265.33root@67e84f80b9d1:/home/jiminny# php artisan crm:hubspot-webhook metrics -T 459 --from 2026-04-15 -DINFOManaging webhookmetrics for date range.Date RangeConfig IDIll Range SummaryDate RangeTotal DaysOldest Data AgeTotal WebhooksDaily AverageActive Companiesiz Daily Breakdown2026-04-15:335,647 webhooks, 88 companies active2026-04-16: 671,679 webhooks, 88 companies active2026-04-17: 58,351 webhooks, 68 companies active| Company Details2026-04-15 to2026-04-173672026-04-15 to 2026-04-1732.0 days ago1,065,677355,225.6789Company 367 (Sensat - 459)Total Webhooks: 796Days Active: 3/3Daily Average: 265.33company (114 total, avg: 38)association_change: 92 total, avg: 46, active: 2 dayscreation: 3 total, avg: 1.5, active: 2 daysproperty_change: 19 total, avg: 9.5, active: 2 daysUnique properties: 4Top properties: hubspot_owner_id(12), domain(3), name(3), phone(1)deal (164 total, avg: 54.67)property_change: 164 total, avg: 54.67, active: 3 daysUnique properties: 8Top properties: notes_last_updated(134), closedate(7), dealstage(5), hs_deal_stage_probability(5), hs_manual_forecast_category(5)contact (518 total, avg: 172.67)property_change:390 total, avg: 130, active: 3 daysUnique properties: 9Top properties: hubspot_owner_id(186), firstname(35), email(35),associatedcompanyid(33), country(33)creation: 36 total, avg: 18, active: 2 daysassociation_change: 92 total, avg: 46, active: 2 daysroote67e84f80b9d1:/home/jiminny# U...
|
NULL
|
5061783765579646234
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2ShellEdit|ViewSessionScriptsProfilesWindowHe iTerm2ShellEdit|ViewSessionScriptsProfilesWindowHelpec2-user@ip-10-20-6-111:~X4Ia6|•95Support Daily - in 2h 5 m100% <978Fri 17 Apr 12:55:181₴81ec2-user@ip-10-20-...88DOCKER• ₴1DEV (-zsh)882APP (-zsh)|-zsh* Review screenp...• X6ec2-user@ip-10-30-...$7Days Active: 3/3Daily Average: 265.33root@67e84f80b9d1:/home/jiminny# php artisan crm:hubspot-webhook metrics -T 459 --from 2026-04-15 -DINFOManaging webhookmetrics for date range.Date RangeConfig IDIll Range SummaryDate RangeTotal DaysOldest Data AgeTotal WebhooksDaily AverageActive Companiesiz Daily Breakdown2026-04-15:335,647 webhooks, 88 companies active2026-04-16: 671,679 webhooks, 88 companies active2026-04-17: 58,351 webhooks, 68 companies active| Company Details2026-04-15 to2026-04-173672026-04-15 to 2026-04-1732.0 days ago1,065,677355,225.6789Company 367 (Sensat - 459)Total Webhooks: 796Days Active: 3/3Daily Average: 265.33company (114 total, avg: 38)association_change: 92 total, avg: 46, active: 2 dayscreation: 3 total, avg: 1.5, active: 2 daysproperty_change: 19 total, avg: 9.5, active: 2 daysUnique properties: 4Top properties: hubspot_owner_id(12), domain(3), name(3), phone(1)deal (164 total, avg: 54.67)property_change: 164 total, avg: 54.67, active: 3 daysUnique properties: 8Top properties: notes_last_updated(134), closedate(7), dealstage(5), hs_deal_stage_probability(5), hs_manual_forecast_category(5)contact (518 total, avg: 172.67)property_change:390 total, avg: 130, active: 3 daysUnique properties: 9Top properties: hubspot_owner_id(186), firstname(35), email(35),associatedcompanyid(33), country(33)creation: 36 total, avg: 18, active: 2 daysassociation_change: 92 total, avg: 46, active: 2 daysroote67e84f80b9d1:/home/jiminny# U...
|
NULL
|
|
45663
|
NULL
|
0
|
2026-04-17T09:55:18.841298+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776419718841_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorToo PhpStormFileEditViewNavigateCodeLaravelRefactorToolsWindowHelpj Support Daily • in 2h 5 m100% C•Fri 17 Apr 12:55:18FV faVsco.s v#11894 on JY-18909-automated-reports-ask-jiminny k ~AutomatedReportsCommandTestvProject v© AutomatedReportsService.php© SendReportJob.php© SendReportMailJob.php© ReportController.php© TokenBuilder.php= custom.log= laravel.log4 SF [jiminny@localhost]C scratch_1.jsonV connect.vueV Onboard.vueA HS_local [jiminny@localhost]A console (EU] x= ids.txt© TeamSetupController.phppnp apl.onp© Filesystem.phpC Team.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.phpcrmenutykeposilorv.onofi crm_configurations [EU]A console [PROD]console SlAGiNG=infection.son.dist© RequestGenerateReportJob.php& OpportunitySyncTrait.php x© Opportunity.php€ InteractsWithPivotTable.php© OpportunityUpdated.php9p 0Tx: Auto vHaycround vEajiminny vM+ INSTALL.mdu.email,M+ INTERNAL_WEBHOOK SETUP© OpportunityStageUpdated.php© OpportunityPendingAiAnalysisAfterStageChanged.php© RunOpportunityAiAnalysis.php026 49 A22 Х 3 Х 104 ^1569E jiminny_storageC ProcessAiAutomationAnalysisResults.php© ImportOpportunityBatch.php© ImportBatchJobTrait.phpsa.*,C Service.php1570t.owner_id FROM social_accounts saM+ licenses.mdM MakefilecachedStagesCc W.*2/41571JOIN users u on u.id = sa.sociable_id1572JOIN teams t 1..n<->1: on t.id = u.team_idO package-lock.jsontrait OpportunitySyncTraitA32 V.2 V.19 ^v 1573WHERE u.team_id = 400 and sa.provider = 'hubspot':= phpstan.neon.dist942private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage= phpstan-baseline.neorIselect * from features:< phpunit.xml960Te raw_sql_query.sql961if ($stage === null) {10/0967$this->importStages(null, $stageId);=1577select * from team_features where feature_id = 40;M+ KEADME.mOộ sonar-project.properties1578963select * from teams where id = 556; # owner: 18101,crm: 477--019select * from crm_configurations where id = 477;E test.pyif ($stage === null) {<> Untitled Diagram.xml1580SELECT * FROM users WHERE id = 18101;ISELECT700$this->logger->info('[HubSpot] Stage does not exist => •.$stageId);Is vetur.config.js=M+ WEBHOOK_FILTERING_IMPLE70/968ШIL1582CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE "' END) AS user_id,U.emall,› ih External Libraries1584v E* Scratches and Consoles969$this->cachedStages[$cacheKey] = $stage;sa.*,v D Database Consoles9701585t.owner_1d FRoM soclal_accounts sa971return $stage;1580Juirusers u on urio = sa.sociaole 10vA-UJOIN teams t 1.n‹->1: on t.id= u.team id972c consoe -u15881589WHERE U.team_id = 556 and sa.provider = 'integration-app';4 DEAL RISKS [EU]A DI [EU]1 usageA EU [EU]974988private function resolveAmount(array $properties): ?stringf...}15901591v A jiminny@localhost1 usageA console [jiminny@localt1592=1593private function parseCleanDatetime(string $datetime): ?Carbonf...}DI [jiminny@localhost]Y8Y10061594A HS_local [iminny@local15951 usageA SF [jiminny@localhost]15961007private function resolveDealProbability(?string $stageProbability): int{...}A zoho_dev ljiminny@loce1017V & PROD1578Tusaue& consoe PrODIA console_1 [PROD]1018private function resolveForecastCategory(?string $forecastCategory): stringf..,1072E DI [PROD]102810001601> LQA2 usagesA QAIorvare runccon noorurxteraurelovarc array moropermes, 1 no0oorcuniryo, VoLe1603> 4 QAI PRODselect * from opportunities where id = 7594349;select * from opportunity_stages where opportunity_id = 7594349 order by created_at desc;select * from business_processes where id = 6024;select * from business_process_stages where stage_id = 16352;select * from business_process_stages where business_process_id = 6024;select * from stages where team_id = 459;select * from teams where id = 459;DCLELCONCAT(u.id, CASE WHEN U.id = t.owner_id THEN ' (owner)' ELSE "' END) AS user_id,v.email,sa.*,t.owner_id FROM social_accounts salJOIN users u on u.id = sa.sociable_idJOIN teams t 1.n<->1: on t.id = u.team_idWHERE u.team_id = 459 and sa.provider ='hubspot':10511604$crmFields = $this->get0pportunitySyncableFields();• A STAGINGA console [STAGING]1004$this->import0pportunityCrmFieldData($properties, $crmFields, $opportunityId);160510331606L console_1 [STAGING]16071034« uranus [STAGING]› DJ ExtensionsLusagesv D Scratches1035private function importOpportunityContacts(Opportunity $opportunity, array $associations): voidf...E phpstorm_shortcuts.txt104810101011SELECT os.stage_id, s.crm_provider_id, s.name, COUNT(*) as cntFROM opportunity_stages osJOIN stages s 1.n<→›1: ON s.id = os.stage_idWHERE os.opportunity_id = 7594349GROUP BY os.stage_id, s.crm_provider_id, s.nameORDER BY cnt DESC;° scratch.txt1049C° scratch_1.json1050* Sync opportunity contacts using differential approach1612_1613SELECT s.id, s.crm_provider_id, s.name, s.team_id, s.crm_configuration_id(° scratch_2.json1051* Inis compares current vs new associacions and only makes necessary changes1052C* scratch_3.json1614E scratch_4.txt1 usage1615ph, scratch_5.php10551070private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void{...J16161617FROM stages sJOIN business_process_stages bps 1<->1..n: ON bps.stage_id = s.idWHERE bps.business_process_id = 6024AND s.crm_provider_id ='contractsent';phỹ scratch_6.phpC° scratch_7.jsonlusage[ 1618C° stage2.json10/4select * from stages where id IN (16352,20612,18281, 7344, 16378, 16309,5036, 15223, 14535, 6293, 12098, 11607)private function getCurrentContactCrmIds(Opportunity $opportunity): array{...}E° test1077<° test.htmllusayepi, tmp.php1078private function logContactAssociationChanges(Opportunity Sopportunity,Shortcuts conflicts: Clone Caret Above and 1 more shortcut conflict with macOS shortcuts. Modify these shortcuts or change nsettings. // Modify Shortcuts // Don't Show Again (32 minutes ago)W Windsurf Teams 1601:39uir-o4 spaces...
|
NULL
|
5845177988310725072
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorToo PhpStormFileEditViewNavigateCodeLaravelRefactorToolsWindowHelpj Support Daily • in 2h 5 m100% C•Fri 17 Apr 12:55:18FV faVsco.s v#11894 on JY-18909-automated-reports-ask-jiminny k ~AutomatedReportsCommandTestvProject v© AutomatedReportsService.php© SendReportJob.php© SendReportMailJob.php© ReportController.php© TokenBuilder.php= custom.log= laravel.log4 SF [jiminny@localhost]C scratch_1.jsonV connect.vueV Onboard.vueA HS_local [jiminny@localhost]A console (EU] x= ids.txt© TeamSetupController.phppnp apl.onp© Filesystem.phpC Team.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.phpcrmenutykeposilorv.onofi crm_configurations [EU]A console [PROD]console SlAGiNG=infection.son.dist© RequestGenerateReportJob.php& OpportunitySyncTrait.php x© Opportunity.php€ InteractsWithPivotTable.php© OpportunityUpdated.php9p 0Tx: Auto vHaycround vEajiminny vM+ INSTALL.mdu.email,M+ INTERNAL_WEBHOOK SETUP© OpportunityStageUpdated.php© OpportunityPendingAiAnalysisAfterStageChanged.php© RunOpportunityAiAnalysis.php026 49 A22 Х 3 Х 104 ^1569E jiminny_storageC ProcessAiAutomationAnalysisResults.php© ImportOpportunityBatch.php© ImportBatchJobTrait.phpsa.*,C Service.php1570t.owner_id FROM social_accounts saM+ licenses.mdM MakefilecachedStagesCc W.*2/41571JOIN users u on u.id = sa.sociable_id1572JOIN teams t 1..n<->1: on t.id = u.team_idO package-lock.jsontrait OpportunitySyncTraitA32 V.2 V.19 ^v 1573WHERE u.team_id = 400 and sa.provider = 'hubspot':= phpstan.neon.dist942private function resolveStage(BusinessProcess $businessProcess, ?string $stageId): ?Stage= phpstan-baseline.neorIselect * from features:< phpunit.xml960Te raw_sql_query.sql961if ($stage === null) {10/0967$this->importStages(null, $stageId);=1577select * from team_features where feature_id = 40;M+ KEADME.mOộ sonar-project.properties1578963select * from teams where id = 556; # owner: 18101,crm: 477--019select * from crm_configurations where id = 477;E test.pyif ($stage === null) {<> Untitled Diagram.xml1580SELECT * FROM users WHERE id = 18101;ISELECT700$this->logger->info('[HubSpot] Stage does not exist => •.$stageId);Is vetur.config.js=M+ WEBHOOK_FILTERING_IMPLE70/968ШIL1582CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE "' END) AS user_id,U.emall,› ih External Libraries1584v E* Scratches and Consoles969$this->cachedStages[$cacheKey] = $stage;sa.*,v D Database Consoles9701585t.owner_1d FRoM soclal_accounts sa971return $stage;1580Juirusers u on urio = sa.sociaole 10vA-UJOIN teams t 1.n‹->1: on t.id= u.team id972c consoe -u15881589WHERE U.team_id = 556 and sa.provider = 'integration-app';4 DEAL RISKS [EU]A DI [EU]1 usageA EU [EU]974988private function resolveAmount(array $properties): ?stringf...}15901591v A jiminny@localhost1 usageA console [jiminny@localt1592=1593private function parseCleanDatetime(string $datetime): ?Carbonf...}DI [jiminny@localhost]Y8Y10061594A HS_local [iminny@local15951 usageA SF [jiminny@localhost]15961007private function resolveDealProbability(?string $stageProbability): int{...}A zoho_dev ljiminny@loce1017V & PROD1578Tusaue& consoe PrODIA console_1 [PROD]1018private function resolveForecastCategory(?string $forecastCategory): stringf..,1072E DI [PROD]102810001601> LQA2 usagesA QAIorvare runccon noorurxteraurelovarc array moropermes, 1 no0oorcuniryo, VoLe1603> 4 QAI PRODselect * from opportunities where id = 7594349;select * from opportunity_stages where opportunity_id = 7594349 order by created_at desc;select * from business_processes where id = 6024;select * from business_process_stages where stage_id = 16352;select * from business_process_stages where business_process_id = 6024;select * from stages where team_id = 459;select * from teams where id = 459;DCLELCONCAT(u.id, CASE WHEN U.id = t.owner_id THEN ' (owner)' ELSE "' END) AS user_id,v.email,sa.*,t.owner_id FROM social_accounts salJOIN users u on u.id = sa.sociable_idJOIN teams t 1.n<->1: on t.id = u.team_idWHERE u.team_id = 459 and sa.provider ='hubspot':10511604$crmFields = $this->get0pportunitySyncableFields();• A STAGINGA console [STAGING]1004$this->import0pportunityCrmFieldData($properties, $crmFields, $opportunityId);160510331606L console_1 [STAGING]16071034« uranus [STAGING]› DJ ExtensionsLusagesv D Scratches1035private function importOpportunityContacts(Opportunity $opportunity, array $associations): voidf...E phpstorm_shortcuts.txt104810101011SELECT os.stage_id, s.crm_provider_id, s.name, COUNT(*) as cntFROM opportunity_stages osJOIN stages s 1.n<→›1: ON s.id = os.stage_idWHERE os.opportunity_id = 7594349GROUP BY os.stage_id, s.crm_provider_id, s.nameORDER BY cnt DESC;° scratch.txt1049C° scratch_1.json1050* Sync opportunity contacts using differential approach1612_1613SELECT s.id, s.crm_provider_id, s.name, s.team_id, s.crm_configuration_id(° scratch_2.json1051* Inis compares current vs new associacions and only makes necessary changes1052C* scratch_3.json1614E scratch_4.txt1 usage1615ph, scratch_5.php10551070private function syncOpportunityContactsDifferential(Opportunity $opportunity, array $contactAssociations): void{...J16161617FROM stages sJOIN business_process_stages bps 1<->1..n: ON bps.stage_id = s.idWHERE bps.business_process_id = 6024AND s.crm_provider_id ='contractsent';phỹ scratch_6.phpC° scratch_7.jsonlusage[ 1618C° stage2.json10/4select * from stages where id IN (16352,20612,18281, 7344, 16378, 16309,5036, 15223, 14535, 6293, 12098, 11607)private function getCurrentContactCrmIds(Opportunity $opportunity): array{...}E° test1077<° test.htmllusayepi, tmp.php1078private function logContactAssociationChanges(Opportunity Sopportunity,Shortcuts conflicts: Clone Caret Above and 1 more shortcut conflict with macOS shortcuts. Modify these shortcuts or change nsettings. // Modify Shortcuts // Don't Show Again (32 minutes ago)W Windsurf Teams 1601:39uir-o4 spaces...
|
NULL
|
|
45743
|
NULL
|
0
|
2026-04-17T10:00:24.928679+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776420024928_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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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 === false || connection?.connected === true)) {
// if (connection && connection.connected === true) {
// if (connection && connection.disconnected === false) {
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 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 });\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 === false || connection?.connected === true)) {\n // if (connection && connection.connected === true) {\n // if (connection && connection.disconnected === false) {\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 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 });\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 === false || connection?.connected === true)) {\n // if (connection && connection.connected === true) {\n // if (connection && connection.disconnected === false) {\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}]...
|
3538231965223053471
|
-8754548301004238554
|
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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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 === false || connection?.connected === true)) {
// if (connection && connection.connected === true) {
// if (connection && connection.disconnected === false) {
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...
|
45741
|
|
45744
|
NULL
|
0
|
2026-04-17T10:00:25.475512+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776420025475_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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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 === false || connection?.connected === true)) {
// if (connection && connection.connected === true) {
// if (connection && connection.disconnected === false) {
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 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 });\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 === false || connection?.connected === true)) {\n // if (connection && connection.connected === true) {\n // if (connection && connection.disconnected === false) {\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 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 });\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 === false || connection?.connected === true)) {\n // if (connection && connection.connected === true) {\n // if (connection && connection.disconnected === false) {\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,"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}]...
|
3538231965223053471
|
-8754548301004238554
|
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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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 === false || connection?.connected === true)) {
// if (connection && connection.connected === true) {
// if (connection && connection.disconnected === false) {
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
|
|
45786
|
NULL
|
0
|
2026-04-17T10:05:18.991578+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776420318991_m2.jpg...
|
PhpStorm
|
faVsco.js – ~/jiminny/app/front-end/src/components faVsco.js – ~/jiminny/app/front-end/src/components/onboard/Onboard.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
9
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout v-bind="{ title: pageTitle }">
<MobileAppDownload
v-if="isSuggestingMobileApp"
@ready="finishOnboarding()"
/>
<div v-else :class="$style.container">
<BuildInfo />
<form>
<!-- GENERAL Section-->
<div :class="$style.sectionTitle">General</div>
<!-- Job title -->
<SelectField
v-if="showJobSelector && jobs.length > 0"
required="required"
name="jobTitleId"
fieldLabel="Select position"
v-model="form.job_title_id"
:options="jobs"
track-by="id"
:hasError="form.errors.has('job_title_id')"
:errorMessage="form.errors.get('job_title_id')"
optionLabel="name"
data-testid="job-title-selector"
/>
<!-- Timezone -->
<SelectField
required="required"
name="timezone"
optionLabel="label"
fieldLabel="Timezone"
:disabled="!timezonesReady"
v-model="form.timezone"
:options="timezonesArray"
:hasError="form.errors.has('timezone')"
:errorMessage="form.errors.get('timezone')"
track-by="tzCode"
/>
<!-- Transcription language -->
<template v-if="canRecord">
<!-- LANGUAGE Section-->
<div :class="$style.sectionTitle">
Languages spoken during calls
<tippy theme="primary" placement="bottom">
<FontAwesomeIcon :icon="faCircleInfo" :class="$style.infoIcon" />
<template #content>
<div :class="$style.textLeft">
Select all languages spoken during call.<br />
We automatically detect languages and if one isn’t identified,
it will be translated into the default language
</div>
</template>
</tippy>
</div>
<UserLocalesInputs
:hasError="
form.errors.has('language') || form.errors.has('locales')
"
:errorMessage="
form.errors.get('language') || form.errors.get('locales')
"
:buttonAttrs="{ mods: 'primary seamless' }"
v-bind="{ userLocales }"
/>
</template>
<!-- PHONE Section-->
<div
v-if="
needsToConfigurePhoneNumber ||
showDeskPhoneNumber ||
needConfigureSoftphoneNumber
"
:class="$style.sectionTitle"
>
Phone
</div>
<!-- Configure Phone number -->
<div :class="$style.panel" v-if="needsToConfigurePhoneNumber">
<PhoneField
required="required"
name="phone"
fieldLabel="Phone number"
v-model="form.phone"
tooltip="We'll use this to send you notifications and identify you."
:hasError="form.errors.has('phone')"
:errorMessage="form.errors.get('phone')"
data-testid="phone-input"
/>
</div>
<!-- Configure Desk Phone number -->
<template v-if="showDeskPhoneNumber">
<PhoneField
name="secondary_phone"
fieldLabel="Desk number"
v-model="form.secondary_phone"
:hasError="form.errors.has('secondary_phone')"
:errorMessage="form.errors.get('secondary_phone')"
data-testid="desk-phone-input"
/>
</template>
<!-- Softphone Number (SMS & Inbound number) -->
<div
:class="[$style.panel, $style.conferenceNumberPanel]"
v-if="needConfigureSoftphoneNumber"
>
<!-- Country Code selector -->
<PhoneField
:class="$style.countryCodeInput"
required="required"
name="softphone_country_code"
fieldLabel="Country"
:initialCountry="softphoneCountryCode"
:separateDialCode="true"
v-model="softphoneCountryCode"
@onCountryChange="softphoneCountryCode = $event.iso2"
/>
<!-- Area Code selector -->
<Field
v-slot="{ field, errorMessage }"
name="softphone_pattern"
:rules="`numeric|min:0|max:${softphonePattern.maxLength}`"
v-model="form.softphone_pattern"
>
<InputField
v-bind="field"
:disabled="form.busy"
fieldLabel="Area Code"
:hasError="!!errorMessage"
data-testid="area-code-input"
/>
</Field>
<!-- Displays the softphone number in a user firendly format, example: "[PHONE]" -->
<InputField
:disabled="form.busy"
readonly
name="softphone_national"
fieldLabel="SMS & Inbound"
v-model="form.softphone_national"
:hasError="form.errors.has('softphone_national')"
data-testid="softphone-input"
/>
<!-- Generate new softphone number -->
<button
type="button"
name="button"
data-testid="button-generate-softphone-number"
@click="getSoftphoneNumber"
:disabled="form.busy"
>
<font-awesome-icon :icon="faSync" />
</button>
<!-- Error messages -->
<p class="error" v-show="form.errors.has('softphone_number')">
{{ form.errors.get("softphone_number") }}
</p>
<ErrorMessage name="softphone_pattern" v-slot="{ message }">
<p class="error" v-show="!!message">
{{ message }}
</p>
</ErrorMessage>
<p class="error" v-show="form.errors.has('softphone_pattern')">
{{ form.errors.get("softphone_pattern") }}
</p>
<p class="error" v-show="form.errors.has('softphone_country_code')">
{{ form.errors.get("softphone_country_code") }}
</p>
</div>
<!-- CONNECT -->
<div
v-if="
hasBrowserExtensionInstaller || hasCrmPermission || sync.ui.visible
"
:class="$style.sectionTitle"
>
Connect/Sync settings
</div>
<!-- CRM connection status -->
<div data-testid="integrations-connections" :class="$style.actionsRows">
<template v-if="hasCrmPermission">
<span>Connect {{ capitalizedCrmName }}</span>
<!-- CRM connected -->
<template v-if="crmConnection">
<AppButton
data-testid="crm-connected"
variant="secondary"
readonly
>
<BrandLogo :name="crmDetails.name" />
Connected
</AppButton>
</template>
<!-- CRM not connected -->
<template v-else>
<AppButton
v-if="crmDetails.viaIntegrationApp"
@click="crmConnectIntegrationApp"
variant="secondary"
:busy="!crmToken"
data-testid="crm-signin"
>
<B...
|
[{"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.23320313,"top":1.0,"width":0.00859375,"height":0.0},"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.009375,"height":0.0},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.00859375,"height":0.0},"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.23320313,"top":1.0,"width":0.008203125,"height":0.0},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<template>\n <WelcomeLayout v-bind=\"{ title: pageTitle }\">\n <MobileAppDownload\n v-if=\"isSuggestingMobileApp\"\n @ready=\"finishOnboarding()\"\n />\n <div v-else :class=\"$style.container\">\n <BuildInfo />\n <form>\n <!-- GENERAL Section-->\n <div :class=\"$style.sectionTitle\">General</div>\n\n <!-- Job title -->\n <SelectField\n v-if=\"showJobSelector && jobs.length > 0\"\n required=\"required\"\n name=\"jobTitleId\"\n fieldLabel=\"Select position\"\n v-model=\"form.job_title_id\"\n :options=\"jobs\"\n track-by=\"id\"\n :hasError=\"form.errors.has('job_title_id')\"\n :errorMessage=\"form.errors.get('job_title_id')\"\n optionLabel=\"name\"\n data-testid=\"job-title-selector\"\n />\n\n <!-- Timezone -->\n <SelectField\n required=\"required\"\n name=\"timezone\"\n optionLabel=\"label\"\n fieldLabel=\"Timezone\"\n :disabled=\"!timezonesReady\"\n v-model=\"form.timezone\"\n :options=\"timezonesArray\"\n :hasError=\"form.errors.has('timezone')\"\n :errorMessage=\"form.errors.get('timezone')\"\n track-by=\"tzCode\"\n />\n <!-- Transcription language -->\n <template v-if=\"canRecord\">\n <!-- LANGUAGE Section-->\n <div :class=\"$style.sectionTitle\">\n Languages spoken during calls\n\n <tippy theme=\"primary\" placement=\"bottom\">\n <FontAwesomeIcon :icon=\"faCircleInfo\" :class=\"$style.infoIcon\" />\n <template #content>\n <div :class=\"$style.textLeft\">\n Select all languages spoken during call.<br />\n We automatically detect languages and if one isn’t identified,\n it will be translated into the default language\n </div>\n </template>\n </tippy>\n </div>\n <UserLocalesInputs\n :hasError=\"\n form.errors.has('language') || form.errors.has('locales')\n \"\n :errorMessage=\"\n form.errors.get('language') || form.errors.get('locales')\n \"\n :buttonAttrs=\"{ mods: 'primary seamless' }\"\n v-bind=\"{ userLocales }\"\n />\n </template>\n\n <!-- PHONE Section-->\n <div\n v-if=\"\n needsToConfigurePhoneNumber ||\n showDeskPhoneNumber ||\n needConfigureSoftphoneNumber\n \"\n :class=\"$style.sectionTitle\"\n >\n Phone\n </div>\n <!-- Configure Phone number -->\n <div :class=\"$style.panel\" v-if=\"needsToConfigurePhoneNumber\">\n <PhoneField\n required=\"required\"\n name=\"phone\"\n fieldLabel=\"Phone number\"\n v-model=\"form.phone\"\n tooltip=\"We'll use this to send you notifications and identify you.\"\n :hasError=\"form.errors.has('phone')\"\n :errorMessage=\"form.errors.get('phone')\"\n data-testid=\"phone-input\"\n />\n </div>\n <!-- Configure Desk Phone number -->\n <template v-if=\"showDeskPhoneNumber\">\n <PhoneField\n name=\"secondary_phone\"\n fieldLabel=\"Desk number\"\n v-model=\"form.secondary_phone\"\n :hasError=\"form.errors.has('secondary_phone')\"\n :errorMessage=\"form.errors.get('secondary_phone')\"\n data-testid=\"desk-phone-input\"\n />\n </template>\n <!-- Softphone Number (SMS & Inbound number) -->\n <div\n :class=\"[$style.panel, $style.conferenceNumberPanel]\"\n v-if=\"needConfigureSoftphoneNumber\"\n >\n <!-- Country Code selector -->\n <PhoneField\n :class=\"$style.countryCodeInput\"\n required=\"required\"\n name=\"softphone_country_code\"\n fieldLabel=\"Country\"\n :initialCountry=\"softphoneCountryCode\"\n :separateDialCode=\"true\"\n v-model=\"softphoneCountryCode\"\n @onCountryChange=\"softphoneCountryCode = $event.iso2\"\n />\n <!-- Area Code selector -->\n <Field\n v-slot=\"{ field, errorMessage }\"\n name=\"softphone_pattern\"\n :rules=\"`numeric|min:0|max:${softphonePattern.maxLength}`\"\n v-model=\"form.softphone_pattern\"\n >\n <InputField\n v-bind=\"field\"\n :disabled=\"form.busy\"\n fieldLabel=\"Area Code\"\n :hasError=\"!!errorMessage\"\n data-testid=\"area-code-input\"\n />\n </Field>\n <!-- Displays the softphone number in a user firendly format, example: \"(361) 345-7280\" -->\n <InputField\n :disabled=\"form.busy\"\n readonly\n name=\"softphone_national\"\n fieldLabel=\"SMS & Inbound\"\n v-model=\"form.softphone_national\"\n :hasError=\"form.errors.has('softphone_national')\"\n data-testid=\"softphone-input\"\n />\n <!-- Generate new softphone number -->\n <button\n type=\"button\"\n name=\"button\"\n data-testid=\"button-generate-softphone-number\"\n @click=\"getSoftphoneNumber\"\n :disabled=\"form.busy\"\n >\n <font-awesome-icon :icon=\"faSync\" />\n </button>\n <!-- Error messages -->\n <p class=\"error\" v-show=\"form.errors.has('softphone_number')\">\n {{ form.errors.get(\"softphone_number\") }}\n </p>\n <ErrorMessage name=\"softphone_pattern\" v-slot=\"{ message }\">\n <p class=\"error\" v-show=\"!!message\">\n {{ message }}\n </p>\n </ErrorMessage>\n <p class=\"error\" v-show=\"form.errors.has('softphone_pattern')\">\n {{ form.errors.get(\"softphone_pattern\") }}\n </p>\n <p class=\"error\" v-show=\"form.errors.has('softphone_country_code')\">\n {{ form.errors.get(\"softphone_country_code\") }}\n </p>\n </div>\n\n <!-- CONNECT -->\n <div\n v-if=\"\n hasBrowserExtensionInstaller || hasCrmPermission || sync.ui.visible\n \"\n :class=\"$style.sectionTitle\"\n >\n Connect/Sync settings\n </div>\n <!-- CRM connection status -->\n <div data-testid=\"integrations-connections\" :class=\"$style.actionsRows\">\n <template v-if=\"hasCrmPermission\">\n <span>Connect {{ capitalizedCrmName }}</span>\n <!-- CRM connected -->\n <template v-if=\"crmConnection\">\n <AppButton\n data-testid=\"crm-connected\"\n variant=\"secondary\"\n readonly\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Connected\n </AppButton>\n </template>\n <!-- CRM not connected -->\n <template v-else>\n <AppButton\n v-if=\"crmDetails.viaIntegrationApp\"\n @click=\"crmConnectIntegrationApp\"\n variant=\"secondary\"\n :busy=\"!crmToken\"\n data-testid=\"crm-signin\"\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Sign in with {{ capitalizedCrmName }}\n </AppButton>\n <AppButton\n v-else\n :href=\"crmConnectUrl\"\n variant=\"secondary\"\n data-testid=\"crm-signin\"\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Sign in with {{ capitalizedCrmName }}\n </AppButton>\n </template>\n </template>\n\n <template v-if=\"sync.ui.visible\">\n <span data-testid=\"sync-text\"\n ><span\n >{{ sync.ui.text\n }}<span v-if=\"sync.ui.required\" :class=\"$style.asterisk\"></span\n ></span>\n <tippy v-if=\"sync.ui.tip\" theme=\"primary\" placement=\"bottom\">\n <FontAwesomeIcon\n :icon=\"faCircleInfo\"\n :class=\"$style.infoIcon\"\n />\n <template #content>\n <div :class=\"$style.textLeft\">\n We will use your email account to give you a more complete\n view of activity related to your customers. Only emails with\n participants connected to an external CRM record are stored\n and displayed.\n </div>\n </template>\n </tippy>\n </span>\n <AppButton\n v-if=\"sync.ui.completed\"\n readonly\n variant=\"secondary\"\n data-testid=\"sync-completed\"\n >\n <BrandLogo :name=\"sync.provider\" />\n Completed\n </AppButton>\n <AppButton\n v-else\n variant=\"secondary\"\n :href=\"sync.ui.href\"\n busy=\"auto\"\n data-testid=\"sync-signin\"\n >\n <BrandLogo :name=\"sync.provider\" />\n Sign in with\n {{ sync.provider == \"office\" ? \"Office 365\" : \"Google\" }}\n </AppButton>\n </template>\n\n <!-- Sidekick Extension Installation status -->\n <BrowserExtensionInstaller\n v-if=\"hasBrowserExtensionInstaller\"\n :extension-id=\"chromeExtensionId\"\n as=\"template\"\n >\n <template #notInstalled=\"{ startChecking }\">\n <span>Jiminny Sidekick Extension</span>\n <AppButton\n :href=\"chromeWebstoreUrl\"\n mods=\"primary outline\"\n @click=\"startChecking\"\n >\n Add to Chrome\n </AppButton>\n </template>\n <template #installed>\n <span>Jiminny Sidekick Extension</span>\n <StatusBadge preset=\"success\">Added</StatusBadge>\n </template>\n </BrowserExtensionInstaller>\n </div>\n </form>\n <!-- Submit Form Button -->\n <AppButton\n @click=\"update\"\n :disabled=\"submitDisabled\"\n variant=\"primary\"\n :class=\"$style.submitButton\"\n mods=\"lg\"\n >\n <template v-if=\"form.busy\">\n <i class=\"fa fa-btn fa-spinner fa-spin fa-icon-padding\"></i>One\n Moment...\n </template>\n <template v-else>\n Let's Get Started!\n <font-awesome-icon\n :icon=\"faLongArrowRight\"\n :class=\"$style.submitIcon\"\n />\n </template>\n </AppButton>\n </div>\n </WelcomeLayout>\n</template>\n\n<script>\n// Icons\nimport { FontAwesomeIcon } from \"@fortawesome/vue-fontawesome\";\nimport { faCheckCircle, faSync } from \"@fortawesome/pro-regular-svg-icons\";\nimport {\n faCircleInfo,\n faLongArrowRight,\n} from \"@fortawesome/pro-light-svg-icons\";\nimport { faSalesforce } from \"@fortawesome/free-brands-svg-icons/faSalesforce\";\n// Components\nimport BrowserExtensionInstaller from \"@/components/shared/BrowserExtensionInstaller/BrowserExtensionInstaller.vue\";\nimport PhoneField from \"@/components/Settings/shared/FormElements/PhoneField.vue\";\nimport SelectField from \"@/components/Settings/shared/FormElements/SelectField.vue\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport InputField from \"@/components/Settings/shared/FormElements/InputField.vue\";\nimport AppButton from \"@/components/shared/Buttons/AppButton.vue\";\nimport StatusBadge from \"@/components/shared/StatusBadge/StatusBadge.vue\";\nimport BrandLogo from \"@/components/shared/BrandLogo.vue\";\nimport MobileAppModal from \"@/components/shared/modals/MobileAppModal.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport MobileAppDownload from \"@/components/onboard/MobileAppDownload.vue\";\n// Utils\nimport { JiminnyForm, localStorage } from \"window\";\nimport axios from \"axios\";\nimport env from \"@/utils/env\";\nimport useragent from \"@/utils/useragent\";\nimport { isMobileAppSupported, isMobile } from \"@/utils/mobileApp\";\nimport useAuthState from \"@/composables/useAuthState\";\nimport { useForm, ErrorMessage, Field, defineRule } from \"vee-validate\";\nimport DOMPurify from \"dompurify\";\nimport useTimezonesCountriesHelper from \"@/composables/useTimezonesCountriesHelper\";\nimport { computed, ref, onMounted, watch, unref, reactive } from \"vue\";\nimport { openModal } from \"jenesius-vue-modal\";\nimport { useStore } from \"vuex\";\nimport * as Sentry from \"@sentry/vue\";\nimport { numeric, min, max } from \"@vee-validate/rules\";\nimport { useLocalStorage } from \"@vueuse/core\";\nimport { showSnackbarError, normalizeError } from \"@/utils\";\nimport { Tippy } from \"vue-tippy\";\nimport intlTelInput from \"intl-tel-input\";\nimport { useUserLocalesSettings } from \"@/composables/useUserLocalesSettings\";\nimport UserLocalesInputs from \"@/components/shared/UserLocalesInputs/UserLocalesInputs.vue\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\nimport useProvidersSyncState from \"./useProvidersSyncState\";\n\ndefineRule(\"numeric\", numeric);\ndefineRule(\"min\", min);\ndefineRule(\"max\", max);\n\nexport default {\n name: \"OnboardPage\",\n components: {\n BuildInfo,\n FontAwesomeIcon,\n BrowserExtensionInstaller,\n PhoneField,\n SelectField,\n InputField,\n Field,\n ErrorMessage,\n AppButton,\n StatusBadge,\n BrandLogo,\n WelcomeLayout,\n MobileAppDownload,\n Tippy,\n UserLocalesInputs,\n },\n setup() {\n const { crmRequired, crmConnection, crmName, crmDetails, backendErrors } =\n window.onboardData;\n\n const countryCodes = [\"CA\", \"US\"];\n const chromeExtensionId = env(\"CHROME_WEB_STORE_EXT_ID\");\n const inChrome = useragent.browser.name === \"Chrome\";\n\n // Store getters\n const store = useStore();\n const user = computed(() => store.getters[\"platform/user\"]);\n const team = computed(() => store.getters[\"platform/team\"]);\n // Actions\n const updateUser = store.dispatch.bind(store, \"platform/updateUser\");\n\n const sync = useProvidersSyncState();\n\n let unwatch = null;\n\n const { validate } = useForm();\n const { canAny } = useAuthState();\n\n const { timezonesArray, initTimezones, timezonesReady } =\n useTimezonesCountriesHelper();\n\n initTimezones();\n\n const finishOnboardingUtils = useFinishOnboarding();\n\n const jobs = ref([]);\n\n const { cache: cachedForm, clearCache: clearCachedForm } =\n useUserCache(\"onboard-form\");\n\n const userLocales = reactive(\n useUserLocalesSettings({\n modelMinLength: 1,\n cachedUserData: cachedForm.value,\n }),\n );\n\n const form = ref(\n new JiminnyForm({\n phone: \"\", // mobile phone\n job_title_id: \"\",\n secondary_phone: \"\", // secondary phone if softphoneSecondaryRoute = 'work'\n country_code: \"\",\n softphone_pattern: null,\n softphone_number: \"\", // telephony provider i.e. sms/softphone number\n softphone_national: \"\", // national formatted sms/softphone number\n timezone: \"\",\n ...cachedForm.value,\n }),\n );\n\n const countries = computed(() => {\n return intlTelInput.getCountryData().map((data) => {\n return {\n id: data.iso2,\n text: `+${data.dialCode}`,\n };\n });\n });\n\n const softphonePattern = computed(() => {\n const countryCode = form.value[\"softphone_country_code\"]?.toUpperCase();\n\n if (!countryCode) return {};\n\n return countryCodes.includes(countryCode)\n ? {\n maxLength: 3,\n helper: \"Please select your area code e.g. 917\",\n }\n : {\n maxLength: 5,\n helper: \"Your local area code e.g. 0161\",\n };\n });\n const chromeWebstoreUrl = ref(null);\n const softphoneCountryCode = ref(\n form.value[\"softphone_country_code\"] || \"\",\n );\n\n watch(\n () => softphoneCountryCode.value,\n (newValue) => {\n onSoftphoneCountryCodeChange(newValue);\n },\n );\n\n // Computed Props\n const userHasTelephonyPermission = computed(() => canAny([\"dial\", \"sms\"]));\n const capitalizedCrmName = crmDetails.displayName;\n const needConfigureSoftphoneNumber = computed(\n () => !user.value.softphoneNumber && userHasTelephonyPermission.value,\n );\n const needsToConfigurePhoneNumber = computed(\n () => user.value.needsToConfigurePhoneNumber,\n );\n const hasBrowserExtensionInstaller = computed(\n () =>\n canAny([\"record.meeting\", \"dial\"]) &&\n user.value.hasSidekickEnabled &&\n inChrome &&\n chromeExtensionId &&\n chromeWebstoreUrl.value,\n );\n const hasCrmPermission = computed(\n () => user.value.crmRequired && crmRequired,\n );\n const crmConnectUrl = computed(\n () => `/auth/redirect/${DOMPurify.sanitize(crmDetails.name)}`,\n );\n\n /** BEGIN: IntegrationApp related functionality */\n\n const crmToken = ref(null);\n\n const crmConnectIntegrationApp = async function () {\n const integrationApp = new IntegrationAppClient({\n token: crmToken.value,\n });\n\n const connection = await integrationApp\n .integration(crmDetails.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n });\n\n if (!connection || connection.connected === false) {\n showSnackbarError(\n \"A connection with your CRM could not be established\",\n );\n return;\n }\n\n try {\n const saveRequest = await axios.post(\"/api/v1/integration-app-connect\");\n\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n return location.reload();\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n };\n\n const prepareIntegrationAppConnection = async function () {\n if (crmDetails?.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n crmToken.value = response.data.token;\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 if (crmRequired) {\n // Prepare the crm token only if the CRM is required\n prepareIntegrationAppConnection();\n }\n /** END: IntegrationApp related functionality */\n\n const showJobSelector = computed(() => !user.value.job);\n const showDeskPhoneNumber = computed(\n () =>\n needsToConfigurePhoneNumber.value &&\n !user.value.secondaryPhone &&\n team.value.softphoneRoutingPreference,\n );\n // Only users who can record calls\n const canRecord = computed(() => canAny([\"record.meeting\", \"dial\"]));\n\n const submitDisabled = computed(() => {\n if (unref(form).busy) return true;\n return sync.anyNeedAuthorization;\n });\n\n // Methods\n function showErrors() {\n if (backendErrors.length > 0) {\n backendErrors.forEach((error) =>\n showSnackbarError(error, undefined, undefined, false),\n );\n }\n }\n\n async function getSoftphoneNumber() {\n form.value.errors.forget();\n\n if (!form.value[\"softphone_country_code\"]) {\n return form.value.errors.set({\n softphone_country_code: [\"The country field is required.\"],\n });\n }\n\n const { valid } = await validate();\n\n if (!valid) {\n return;\n }\n\n try {\n const params = {\n pattern: form.value[\"softphone_pattern\"],\n country_code: form.value[\"softphone_country_code\"].toUpperCase(),\n capabilities: [\"voice\", \"sms\"],\n };\n\n /**\n * Exemplary response:\n * {\n * \"number\": \"+447782581047\",\n * \"national\": \"07782 581322\", // national is formatted number\n * \"pattern\": \"7782\" // patrten is area code\n * }\n */\n const { data } = await axios.get(\"/api/v1/phone-numbers\", {\n params,\n });\n\n form.value[\"softphone_number\"] = data?.number || \"\";\n form.value[\"softphone_national\"] = data?.national || \"\";\n form.value[\"softphone_pattern\"] = data?.pattern || null;\n } catch (errors) {\n form.value[\"softphone_number\"] = \"\";\n form.value[\"softphone_national\"] = \"\";\n form.value[\"softphone_pattern\"] = null;\n\n handleErrors(errors.response.data);\n }\n }\n\n function handleErrors(errors) {\n if (Object.prototype.hasOwnProperty.call(errors, \"errors\")) {\n form.value.errors.set(errors.errors);\n } else {\n form.value.errors.set(errors);\n }\n }\n\n async function getJobTitles() {\n const response = await axios.get(\n `/api/v1/organizations/${team.value.id}/job-titles`,\n );\n\n jobs.value = response.data;\n }\n\n function preselectSoftphoneCountryCode() {\n form.value[\"softphone_country_code\"] ||= selectedCountry()?.id || \"\";\n }\n\n function selectedCountry() {\n const countryCode = user.value.countryCode?.toLowerCase() || \"\";\n\n if (!countryCode) return \"\";\n\n return countries.value.find((country) => country.id === countryCode);\n }\n\n function onSoftphoneCountryCodeChange(countryCode) {\n form.value[\"softphone_country_code\"] = countryCode;\n form.value[\"softphone_pattern\"] = null;\n form.value[\"softphone_national\"] = \"\";\n form.value[\"softphone_number\"] = \"\";\n getSoftphoneNumber();\n }\n\n async function update() {\n try {\n form.value.busy = true;\n const payload = form.value;\n payload[\"country_code\"] = form.value[\"country_code\"].toUpperCase();\n Object.assign(payload, unref(userLocales.formData));\n\n await axios.post(\"/onboard\", payload);\n\n form.value.errors.forget();\n localStorage.setItem(\"showVerifyCallerIdModal\", true);\n updateUser();\n clearCachedForm();\n finishOnboardingUtils.onOnboardReady();\n } catch (errors) {\n const response = errors.response.data;\n const errorData = Object.hasOwn(response, \"errors\")\n ? errors.response.data.errors\n : errors.response.data;\n\n form.value.errors.set(errorData);\n if (errorData.sync_email) showSnackbarError(errorData.sync_email[0]);\n if (errorData.sync_calendar)\n showSnackbarError(errorData.sync_calendar[0]);\n } finally {\n form.value.busy = false;\n }\n }\n\n function resetLanguageError(value) {\n if (value) {\n form.value.errors.set(\"language\", \"\");\n }\n }\n\n // preselect timezone\n unwatch = watch(\n () => timezonesReady.value,\n (ready) => {\n // user already selected a timezone\n if (form.value.timezone) return;\n if (!ready) return;\n\n const browserTimezone =\n Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n const hasBrowserTimezone = timezonesArray.value.some(\n ({ tzCode }) => tzCode === browserTimezone,\n );\n\n if (hasBrowserTimezone) {\n form.value[\"timezone\"] = browserTimezone;\n } else {\n // Fallback to the initial user timezone (which should match the team timezone)\n form.value[\"timezone\"] = user.value?.timezone || \"\";\n\n const errorMessage = `Browser timezone doesn't match any of our timezones. Will fallback to the initial user timezone (which should match the team's timezone). Browser timezone: \"${browserTimezone}\"`;\n\n console.error(errorMessage);\n Sentry.captureException(new Error(errorMessage));\n }\n\n // Stop watching\n if (unwatch) {\n unwatch();\n }\n },\n { immediate: true },\n );\n\n onMounted(() => {\n showErrors();\n\n if (needConfigureSoftphoneNumber.value) {\n preselectSoftphoneCountryCode();\n if (!form.value.softphone_number) getSoftphoneNumber();\n }\n\n form.value[\"phone\"] ||= user.value.phone || \"\";\n form.value[\"secondary_phone\"] ||= user.value.secondaryPhone || \"\";\n form.value[\"softphone_number\"] ||= user.value.softphoneNumber || \"\";\n form.value[\"job_title_id\"] ||= user.value.job?.id || false;\n\n if (canRecord.value) {\n form.value[\"language\"] ||= \"\";\n }\n\n if (showJobSelector.value) getJobTitles();\n\n const webStoreUrlLink = document.querySelector(\n \"link[rel=chrome-webstore-item]\",\n );\n\n if (webStoreUrlLink) {\n chromeWebstoreUrl.value = DOMPurify.sanitize(\n webStoreUrlLink.getAttribute(\"href\"),\n );\n }\n });\n\n watch(\n form,\n (form) => {\n cachedForm.value = form;\n },\n { deep: true },\n );\n\n watch(\n () => userLocales.formData,\n (userLocales) => {\n cachedForm.value = {\n ...cachedForm.value,\n ...userLocales,\n };\n },\n );\n\n return {\n submitDisabled,\n ...finishOnboardingUtils,\n userLocales,\n sync,\n clearCachedForm,\n validate,\n faCircleInfo,\n faLongArrowRight,\n faSalesforce,\n faCheckCircle,\n faSync,\n timezonesArray,\n timezonesReady,\n crmConnection,\n crmName,\n crmDetails,\n softphoneCountryCode,\n countries,\n softphonePattern,\n chromeWebstoreUrl,\n chromeExtensionId,\n user,\n capitalizedCrmName,\n needConfigureSoftphoneNumber,\n needsToConfigurePhoneNumber,\n hasBrowserExtensionInstaller,\n hasCrmPermission,\n crmConnectUrl,\n crmConnectIntegrationApp,\n showJobSelector,\n showDeskPhoneNumber,\n canRecord,\n getSoftphoneNumber,\n update,\n resetLanguageError,\n form,\n jobs,\n crmToken,\n };\n },\n};\n\nfunction useFinishOnboarding() {\n const isSuggestingMobileApp = ref(null);\n const pageTitle = computed(() =>\n isMobileAppSupported && unref(isSuggestingMobileApp)\n ? \"Download App\"\n : \"Update your information\",\n );\n\n const store = useStore();\n\n const isWhiteLabel = computed(() => store.getters[\"platform/isWhiteLabel\"]);\n\n async function suggestMobileApp() {\n isSuggestingMobileApp.value = isMobileAppSupported;\n\n // for supported phones a corresponding download component is displayed\n if (unref(isSuggestingMobileApp)) return;\n\n // for non mobile devices a modal is displayed and an action is required\n if (!isMobile) await openMobileAppModal();\n\n finishOnboarding();\n }\n\n async function finishOnboarding() {\n window.location = \"/dashboard\";\n }\n\n async function openMobileAppModal() {\n const modal = await openModal(MobileAppModal);\n\n return new Promise((resolve) => (modal.onclose = resolve));\n }\n\n function onOnboardReady() {\n if (unref(isWhiteLabel)) return finishOnboarding();\n\n suggestMobileApp();\n }\n\n return {\n pageTitle,\n isSuggestingMobileApp,\n finishOnboarding,\n onOnboardReady,\n };\n}\n\nfunction useUserCache(key, defaultValue = {}) {\n const store = useStore();\n const id = computed(() => store.getters[\"platform/user\"]?.id);\n const cache = useLocalStorage(key, {});\n\n function clearCache() {\n cache.value = undefined;\n }\n\n return {\n cache: computed({\n get() {\n return cache.value[id.value] || defaultValue;\n },\n set(value) {\n cache.value = {\n [id.value]: value,\n };\n },\n }),\n clearCache,\n };\n}\n</script>\n\n<style module lang=\"less\" src=\"./Onboard.less\"></style>","depth":4,"value":"<template>\n <WelcomeLayout v-bind=\"{ title: pageTitle }\">\n <MobileAppDownload\n v-if=\"isSuggestingMobileApp\"\n @ready=\"finishOnboarding()\"\n />\n <div v-else :class=\"$style.container\">\n <BuildInfo />\n <form>\n <!-- GENERAL Section-->\n <div :class=\"$style.sectionTitle\">General</div>\n\n <!-- Job title -->\n <SelectField\n v-if=\"showJobSelector && jobs.length > 0\"\n required=\"required\"\n name=\"jobTitleId\"\n fieldLabel=\"Select position\"\n v-model=\"form.job_title_id\"\n :options=\"jobs\"\n track-by=\"id\"\n :hasError=\"form.errors.has('job_title_id')\"\n :errorMessage=\"form.errors.get('job_title_id')\"\n optionLabel=\"name\"\n data-testid=\"job-title-selector\"\n />\n\n <!-- Timezone -->\n <SelectField\n required=\"required\"\n name=\"timezone\"\n optionLabel=\"label\"\n fieldLabel=\"Timezone\"\n :disabled=\"!timezonesReady\"\n v-model=\"form.timezone\"\n :options=\"timezonesArray\"\n :hasError=\"form.errors.has('timezone')\"\n :errorMessage=\"form.errors.get('timezone')\"\n track-by=\"tzCode\"\n />\n <!-- Transcription language -->\n <template v-if=\"canRecord\">\n <!-- LANGUAGE Section-->\n <div :class=\"$style.sectionTitle\">\n Languages spoken during calls\n\n <tippy theme=\"primary\" placement=\"bottom\">\n <FontAwesomeIcon :icon=\"faCircleInfo\" :class=\"$style.infoIcon\" />\n <template #content>\n <div :class=\"$style.textLeft\">\n Select all languages spoken during call.<br />\n We automatically detect languages and if one isn’t identified,\n it will be translated into the default language\n </div>\n </template>\n </tippy>\n </div>\n <UserLocalesInputs\n :hasError=\"\n form.errors.has('language') || form.errors.has('locales')\n \"\n :errorMessage=\"\n form.errors.get('language') || form.errors.get('locales')\n \"\n :buttonAttrs=\"{ mods: 'primary seamless' }\"\n v-bind=\"{ userLocales }\"\n />\n </template>\n\n <!-- PHONE Section-->\n <div\n v-if=\"\n needsToConfigurePhoneNumber ||\n showDeskPhoneNumber ||\n needConfigureSoftphoneNumber\n \"\n :class=\"$style.sectionTitle\"\n >\n Phone\n </div>\n <!-- Configure Phone number -->\n <div :class=\"$style.panel\" v-if=\"needsToConfigurePhoneNumber\">\n <PhoneField\n required=\"required\"\n name=\"phone\"\n fieldLabel=\"Phone number\"\n v-model=\"form.phone\"\n tooltip=\"We'll use this to send you notifications and identify you.\"\n :hasError=\"form.errors.has('phone')\"\n :errorMessage=\"form.errors.get('phone')\"\n data-testid=\"phone-input\"\n />\n </div>\n <!-- Configure Desk Phone number -->\n <template v-if=\"showDeskPhoneNumber\">\n <PhoneField\n name=\"secondary_phone\"\n fieldLabel=\"Desk number\"\n v-model=\"form.secondary_phone\"\n :hasError=\"form.errors.has('secondary_phone')\"\n :errorMessage=\"form.errors.get('secondary_phone')\"\n data-testid=\"desk-phone-input\"\n />\n </template>\n <!-- Softphone Number (SMS & Inbound number) -->\n <div\n :class=\"[$style.panel, $style.conferenceNumberPanel]\"\n v-if=\"needConfigureSoftphoneNumber\"\n >\n <!-- Country Code selector -->\n <PhoneField\n :class=\"$style.countryCodeInput\"\n required=\"required\"\n name=\"softphone_country_code\"\n fieldLabel=\"Country\"\n :initialCountry=\"softphoneCountryCode\"\n :separateDialCode=\"true\"\n v-model=\"softphoneCountryCode\"\n @onCountryChange=\"softphoneCountryCode = $event.iso2\"\n />\n <!-- Area Code selector -->\n <Field\n v-slot=\"{ field, errorMessage }\"\n name=\"softphone_pattern\"\n :rules=\"`numeric|min:0|max:${softphonePattern.maxLength}`\"\n v-model=\"form.softphone_pattern\"\n >\n <InputField\n v-bind=\"field\"\n :disabled=\"form.busy\"\n fieldLabel=\"Area Code\"\n :hasError=\"!!errorMessage\"\n data-testid=\"area-code-input\"\n />\n </Field>\n <!-- Displays the softphone number in a user firendly format, example: \"(361) 345-7280\" -->\n <InputField\n :disabled=\"form.busy\"\n readonly\n name=\"softphone_national\"\n fieldLabel=\"SMS & Inbound\"\n v-model=\"form.softphone_national\"\n :hasError=\"form.errors.has('softphone_national')\"\n data-testid=\"softphone-input\"\n />\n <!-- Generate new softphone number -->\n <button\n type=\"button\"\n name=\"button\"\n data-testid=\"button-generate-softphone-number\"\n @click=\"getSoftphoneNumber\"\n :disabled=\"form.busy\"\n >\n <font-awesome-icon :icon=\"faSync\" />\n </button>\n <!-- Error messages -->\n <p class=\"error\" v-show=\"form.errors.has('softphone_number')\">\n {{ form.errors.get(\"softphone_number\") }}\n </p>\n <ErrorMessage name=\"softphone_pattern\" v-slot=\"{ message }\">\n <p class=\"error\" v-show=\"!!message\">\n {{ message }}\n </p>\n </ErrorMessage>\n <p class=\"error\" v-show=\"form.errors.has('softphone_pattern')\">\n {{ form.errors.get(\"softphone_pattern\") }}\n </p>\n <p class=\"error\" v-show=\"form.errors.has('softphone_country_code')\">\n {{ form.errors.get(\"softphone_country_code\") }}\n </p>\n </div>\n\n <!-- CONNECT -->\n <div\n v-if=\"\n hasBrowserExtensionInstaller || hasCrmPermission || sync.ui.visible\n \"\n :class=\"$style.sectionTitle\"\n >\n Connect/Sync settings\n </div>\n <!-- CRM connection status -->\n <div data-testid=\"integrations-connections\" :class=\"$style.actionsRows\">\n <template v-if=\"hasCrmPermission\">\n <span>Connect {{ capitalizedCrmName }}</span>\n <!-- CRM connected -->\n <template v-if=\"crmConnection\">\n <AppButton\n data-testid=\"crm-connected\"\n variant=\"secondary\"\n readonly\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Connected\n </AppButton>\n </template>\n <!-- CRM not connected -->\n <template v-else>\n <AppButton\n v-if=\"crmDetails.viaIntegrationApp\"\n @click=\"crmConnectIntegrationApp\"\n variant=\"secondary\"\n :busy=\"!crmToken\"\n data-testid=\"crm-signin\"\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Sign in with {{ capitalizedCrmName }}\n </AppButton>\n <AppButton\n v-else\n :href=\"crmConnectUrl\"\n variant=\"secondary\"\n data-testid=\"crm-signin\"\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Sign in with {{ capitalizedCrmName }}\n </AppButton>\n </template>\n </template>\n\n <template v-if=\"sync.ui.visible\">\n <span data-testid=\"sync-text\"\n ><span\n >{{ sync.ui.text\n }}<span v-if=\"sync.ui.required\" :class=\"$style.asterisk\"></span\n ></span>\n <tippy v-if=\"sync.ui.tip\" theme=\"primary\" placement=\"bottom\">\n <FontAwesomeIcon\n :icon=\"faCircleInfo\"\n :class=\"$style.infoIcon\"\n />\n <template #content>\n <div :class=\"$style.textLeft\">\n We will use your email account to give you a more complete\n view of activity related to your customers. Only emails with\n participants connected to an external CRM record are stored\n and displayed.\n </div>\n </template>\n </tippy>\n </span>\n <AppButton\n v-if=\"sync.ui.completed\"\n readonly\n variant=\"secondary\"\n data-testid=\"sync-completed\"\n >\n <BrandLogo :name=\"sync.provider\" />\n Completed\n </AppButton>\n <AppButton\n v-else\n variant=\"secondary\"\n :href=\"sync.ui.href\"\n busy=\"auto\"\n data-testid=\"sync-signin\"\n >\n <BrandLogo :name=\"sync.provider\" />\n Sign in with\n {{ sync.provider == \"office\" ? \"Office 365\" : \"Google\" }}\n </AppButton>\n </template>\n\n <!-- Sidekick Extension Installation status -->\n <BrowserExtensionInstaller\n v-if=\"hasBrowserExtensionInstaller\"\n :extension-id=\"chromeExtensionId\"\n as=\"template\"\n >\n <template #notInstalled=\"{ startChecking }\">\n <span>Jiminny Sidekick Extension</span>\n <AppButton\n :href=\"chromeWebstoreUrl\"\n mods=\"primary outline\"\n @click=\"startChecking\"\n >\n Add to Chrome\n </AppButton>\n </template>\n <template #installed>\n <span>Jiminny Sidekick Extension</span>\n <StatusBadge preset=\"success\">Added</StatusBadge>\n </template>\n </BrowserExtensionInstaller>\n </div>\n </form>\n <!-- Submit Form Button -->\n <AppButton\n @click=\"update\"\n :disabled=\"submitDisabled\"\n variant=\"primary\"\n :class=\"$style.submitButton\"\n mods=\"lg\"\n >\n <template v-if=\"form.busy\">\n <i class=\"fa fa-btn fa-spinner fa-spin fa-icon-padding\"></i>One\n Moment...\n </template>\n <template v-else>\n Let's Get Started!\n <font-awesome-icon\n :icon=\"faLongArrowRight\"\n :class=\"$style.submitIcon\"\n />\n </template>\n </AppButton>\n </div>\n </WelcomeLayout>\n</template>\n\n<script>\n// Icons\nimport { FontAwesomeIcon } from \"@fortawesome/vue-fontawesome\";\nimport { faCheckCircle, faSync } from \"@fortawesome/pro-regular-svg-icons\";\nimport {\n faCircleInfo,\n faLongArrowRight,\n} from \"@fortawesome/pro-light-svg-icons\";\nimport { faSalesforce } from \"@fortawesome/free-brands-svg-icons/faSalesforce\";\n// Components\nimport BrowserExtensionInstaller from \"@/components/shared/BrowserExtensionInstaller/BrowserExtensionInstaller.vue\";\nimport PhoneField from \"@/components/Settings/shared/FormElements/PhoneField.vue\";\nimport SelectField from \"@/components/Settings/shared/FormElements/SelectField.vue\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport InputField from \"@/components/Settings/shared/FormElements/InputField.vue\";\nimport AppButton from \"@/components/shared/Buttons/AppButton.vue\";\nimport StatusBadge from \"@/components/shared/StatusBadge/StatusBadge.vue\";\nimport BrandLogo from \"@/components/shared/BrandLogo.vue\";\nimport MobileAppModal from \"@/components/shared/modals/MobileAppModal.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport MobileAppDownload from \"@/components/onboard/MobileAppDownload.vue\";\n// Utils\nimport { JiminnyForm, localStorage } from \"window\";\nimport axios from \"axios\";\nimport env from \"@/utils/env\";\nimport useragent from \"@/utils/useragent\";\nimport { isMobileAppSupported, isMobile } from \"@/utils/mobileApp\";\nimport useAuthState from \"@/composables/useAuthState\";\nimport { useForm, ErrorMessage, Field, defineRule } from \"vee-validate\";\nimport DOMPurify from \"dompurify\";\nimport useTimezonesCountriesHelper from \"@/composables/useTimezonesCountriesHelper\";\nimport { computed, ref, onMounted, watch, unref, reactive } from \"vue\";\nimport { openModal } from \"jenesius-vue-modal\";\nimport { useStore } from \"vuex\";\nimport * as Sentry from \"@sentry/vue\";\nimport { numeric, min, max } from \"@vee-validate/rules\";\nimport { useLocalStorage } from \"@vueuse/core\";\nimport { showSnackbarError, normalizeError } from \"@/utils\";\nimport { Tippy } from \"vue-tippy\";\nimport intlTelInput from \"intl-tel-input\";\nimport { useUserLocalesSettings } from \"@/composables/useUserLocalesSettings\";\nimport UserLocalesInputs from \"@/components/shared/UserLocalesInputs/UserLocalesInputs.vue\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\nimport useProvidersSyncState from \"./useProvidersSyncState\";\n\ndefineRule(\"numeric\", numeric);\ndefineRule(\"min\", min);\ndefineRule(\"max\", max);\n\nexport default {\n name: \"OnboardPage\",\n components: {\n BuildInfo,\n FontAwesomeIcon,\n BrowserExtensionInstaller,\n PhoneField,\n SelectField,\n InputField,\n Field,\n ErrorMessage,\n AppButton,\n StatusBadge,\n BrandLogo,\n WelcomeLayout,\n MobileAppDownload,\n Tippy,\n UserLocalesInputs,\n },\n setup() {\n const { crmRequired, crmConnection, crmName, crmDetails, backendErrors } =\n window.onboardData;\n\n const countryCodes = [\"CA\", \"US\"];\n const chromeExtensionId = env(\"CHROME_WEB_STORE_EXT_ID\");\n const inChrome = useragent.browser.name === \"Chrome\";\n\n // Store getters\n const store = useStore();\n const user = computed(() => store.getters[\"platform/user\"]);\n const team = computed(() => store.getters[\"platform/team\"]);\n // Actions\n const updateUser = store.dispatch.bind(store, \"platform/updateUser\");\n\n const sync = useProvidersSyncState();\n\n let unwatch = null;\n\n const { validate } = useForm();\n const { canAny } = useAuthState();\n\n const { timezonesArray, initTimezones, timezonesReady } =\n useTimezonesCountriesHelper();\n\n initTimezones();\n\n const finishOnboardingUtils = useFinishOnboarding();\n\n const jobs = ref([]);\n\n const { cache: cachedForm, clearCache: clearCachedForm } =\n useUserCache(\"onboard-form\");\n\n const userLocales = reactive(\n useUserLocalesSettings({\n modelMinLength: 1,\n cachedUserData: cachedForm.value,\n }),\n );\n\n const form = ref(\n new JiminnyForm({\n phone: \"\", // mobile phone\n job_title_id: \"\",\n secondary_phone: \"\", // secondary phone if softphoneSecondaryRoute = 'work'\n country_code: \"\",\n softphone_pattern: null,\n softphone_number: \"\", // telephony provider i.e. sms/softphone number\n softphone_national: \"\", // national formatted sms/softphone number\n timezone: \"\",\n ...cachedForm.value,\n }),\n );\n\n const countries = computed(() => {\n return intlTelInput.getCountryData().map((data) => {\n return {\n id: data.iso2,\n text: `+${data.dialCode}`,\n };\n });\n });\n\n const softphonePattern = computed(() => {\n const countryCode = form.value[\"softphone_country_code\"]?.toUpperCase();\n\n if (!countryCode) return {};\n\n return countryCodes.includes(countryCode)\n ? {\n maxLength: 3,\n helper: \"Please select your area code e.g. 917\",\n }\n : {\n maxLength: 5,\n helper: \"Your local area code e.g. 0161\",\n };\n });\n const chromeWebstoreUrl = ref(null);\n const softphoneCountryCode = ref(\n form.value[\"softphone_country_code\"] || \"\",\n );\n\n watch(\n () => softphoneCountryCode.value,\n (newValue) => {\n onSoftphoneCountryCodeChange(newValue);\n },\n );\n\n // Computed Props\n const userHasTelephonyPermission = computed(() => canAny([\"dial\", \"sms\"]));\n const capitalizedCrmName = crmDetails.displayName;\n const needConfigureSoftphoneNumber = computed(\n () => !user.value.softphoneNumber && userHasTelephonyPermission.value,\n );\n const needsToConfigurePhoneNumber = computed(\n () => user.value.needsToConfigurePhoneNumber,\n );\n const hasBrowserExtensionInstaller = computed(\n () =>\n canAny([\"record.meeting\", \"dial\"]) &&\n user.value.hasSidekickEnabled &&\n inChrome &&\n chromeExtensionId &&\n chromeWebstoreUrl.value,\n );\n const hasCrmPermission = computed(\n () => user.value.crmRequired && crmRequired,\n );\n const crmConnectUrl = computed(\n () => `/auth/redirect/${DOMPurify.sanitize(crmDetails.name)}`,\n );\n\n /** BEGIN: IntegrationApp related functionality */\n\n const crmToken = ref(null);\n\n const crmConnectIntegrationApp = async function () {\n const integrationApp = new IntegrationAppClient({\n token: crmToken.value,\n });\n\n const connection = await integrationApp\n .integration(crmDetails.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n });\n\n if (!connection || connection.connected === false) {\n showSnackbarError(\n \"A connection with your CRM could not be established\",\n );\n return;\n }\n\n try {\n const saveRequest = await axios.post(\"/api/v1/integration-app-connect\");\n\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n return location.reload();\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n };\n\n const prepareIntegrationAppConnection = async function () {\n if (crmDetails?.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n crmToken.value = response.data.token;\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 if (crmRequired) {\n // Prepare the crm token only if the CRM is required\n prepareIntegrationAppConnection();\n }\n /** END: IntegrationApp related functionality */\n\n const showJobSelector = computed(() => !user.value.job);\n const showDeskPhoneNumber = computed(\n () =>\n needsToConfigurePhoneNumber.value &&\n !user.value.secondaryPhone &&\n team.value.softphoneRoutingPreference,\n );\n // Only users who can record calls\n const canRecord = computed(() => canAny([\"record.meeting\", \"dial\"]));\n\n const submitDisabled = computed(() => {\n if (unref(form).busy) return true;\n return sync.anyNeedAuthorization;\n });\n\n // Methods\n function showErrors() {\n if (backendErrors.length > 0) {\n backendErrors.forEach((error) =>\n showSnackbarError(error, undefined, undefined, false),\n );\n }\n }\n\n async function getSoftphoneNumber() {\n form.value.errors.forget();\n\n if (!form.value[\"softphone_country_code\"]) {\n return form.value.errors.set({\n softphone_country_code: [\"The country field is required.\"],\n });\n }\n\n const { valid } = await validate();\n\n if (!valid) {\n return;\n }\n\n try {\n const params = {\n pattern: form.value[\"softphone_pattern\"],\n country_code: form.value[\"softphone_country_code\"].toUpperCase(),\n capabilities: [\"voice\", \"sms\"],\n };\n\n /**\n * Exemplary response:\n * {\n * \"number\": \"+447782581047\",\n * \"national\": \"07782 581322\", // national is formatted number\n * \"pattern\": \"7782\" // patrten is area code\n * }\n */\n const { data } = await axios.get(\"/api/v1/phone-numbers\", {\n params,\n });\n\n form.value[\"softphone_number\"] = data?.number || \"\";\n form.value[\"softphone_national\"] = data?.national || \"\";\n form.value[\"softphone_pattern\"] = data?.pattern || null;\n } catch (errors) {\n form.value[\"softphone_number\"] = \"\";\n form.value[\"softphone_national\"] = \"\";\n form.value[\"softphone_pattern\"] = null;\n\n handleErrors(errors.response.data);\n }\n }\n\n function handleErrors(errors) {\n if (Object.prototype.hasOwnProperty.call(errors, \"errors\")) {\n form.value.errors.set(errors.errors);\n } else {\n form.value.errors.set(errors);\n }\n }\n\n async function getJobTitles() {\n const response = await axios.get(\n `/api/v1/organizations/${team.value.id}/job-titles`,\n );\n\n jobs.value = response.data;\n }\n\n function preselectSoftphoneCountryCode() {\n form.value[\"softphone_country_code\"] ||= selectedCountry()?.id || \"\";\n }\n\n function selectedCountry() {\n const countryCode = user.value.countryCode?.toLowerCase() || \"\";\n\n if (!countryCode) return \"\";\n\n return countries.value.find((country) => country.id === countryCode);\n }\n\n function onSoftphoneCountryCodeChange(countryCode) {\n form.value[\"softphone_country_code\"] = countryCode;\n form.value[\"softphone_pattern\"] = null;\n form.value[\"softphone_national\"] = \"\";\n form.value[\"softphone_number\"] = \"\";\n getSoftphoneNumber();\n }\n\n async function update() {\n try {\n form.value.busy = true;\n const payload = form.value;\n payload[\"country_code\"] = form.value[\"country_code\"].toUpperCase();\n Object.assign(payload, unref(userLocales.formData));\n\n await axios.post(\"/onboard\", payload);\n\n form.value.errors.forget();\n localStorage.setItem(\"showVerifyCallerIdModal\", true);\n updateUser();\n clearCachedForm();\n finishOnboardingUtils.onOnboardReady();\n } catch (errors) {\n const response = errors.response.data;\n const errorData = Object.hasOwn(response, \"errors\")\n ? errors.response.data.errors\n : errors.response.data;\n\n form.value.errors.set(errorData);\n if (errorData.sync_email) showSnackbarError(errorData.sync_email[0]);\n if (errorData.sync_calendar)\n showSnackbarError(errorData.sync_calendar[0]);\n } finally {\n form.value.busy = false;\n }\n }\n\n function resetLanguageError(value) {\n if (value) {\n form.value.errors.set(\"language\", \"\");\n }\n }\n\n // preselect timezone\n unwatch = watch(\n () => timezonesReady.value,\n (ready) => {\n // user already selected a timezone\n if (form.value.timezone) return;\n if (!ready) return;\n\n const browserTimezone =\n Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n const hasBrowserTimezone = timezonesArray.value.some(\n ({ tzCode }) => tzCode === browserTimezone,\n );\n\n if (hasBrowserTimezone) {\n form.value[\"timezone\"] = browserTimezone;\n } else {\n // Fallback to the initial user timezone (which should match the team timezone)\n form.value[\"timezone\"] = user.value?.timezone || \"\";\n\n const errorMessage = `Browser timezone doesn't match any of our timezones. Will fallback to the initial user timezone (which should match the team's timezone). Browser timezone: \"${browserTimezone}\"`;\n\n console.error(errorMessage);\n Sentry.captureException(new Error(errorMessage));\n }\n\n // Stop watching\n if (unwatch) {\n unwatch();\n }\n },\n { immediate: true },\n );\n\n onMounted(() => {\n showErrors();\n\n if (needConfigureSoftphoneNumber.value) {\n preselectSoftphoneCountryCode();\n if (!form.value.softphone_number) getSoftphoneNumber();\n }\n\n form.value[\"phone\"] ||= user.value.phone || \"\";\n form.value[\"secondary_phone\"] ||= user.value.secondaryPhone || \"\";\n form.value[\"softphone_number\"] ||= user.value.softphoneNumber || \"\";\n form.value[\"job_title_id\"] ||= user.value.job?.id || false;\n\n if (canRecord.value) {\n form.value[\"language\"] ||= \"\";\n }\n\n if (showJobSelector.value) getJobTitles();\n\n const webStoreUrlLink = document.querySelector(\n \"link[rel=chrome-webstore-item]\",\n );\n\n if (webStoreUrlLink) {\n chromeWebstoreUrl.value = DOMPurify.sanitize(\n webStoreUrlLink.getAttribute(\"href\"),\n );\n }\n });\n\n watch(\n form,\n (form) => {\n cachedForm.value = form;\n },\n { deep: true },\n );\n\n watch(\n () => userLocales.formData,\n (userLocales) => {\n cachedForm.value = {\n ...cachedForm.value,\n ...userLocales,\n };\n },\n );\n\n return {\n submitDisabled,\n ...finishOnboardingUtils,\n userLocales,\n sync,\n clearCachedForm,\n validate,\n faCircleInfo,\n faLongArrowRight,\n faSalesforce,\n faCheckCircle,\n faSync,\n timezonesArray,\n timezonesReady,\n crmConnection,\n crmName,\n crmDetails,\n softphoneCountryCode,\n countries,\n softphonePattern,\n chromeWebstoreUrl,\n chromeExtensionId,\n user,\n capitalizedCrmName,\n needConfigureSoftphoneNumber,\n needsToConfigurePhoneNumber,\n hasBrowserExtensionInstaller,\n hasCrmPermission,\n crmConnectUrl,\n crmConnectIntegrationApp,\n showJobSelector,\n showDeskPhoneNumber,\n canRecord,\n getSoftphoneNumber,\n update,\n resetLanguageError,\n form,\n jobs,\n crmToken,\n };\n },\n};\n\nfunction useFinishOnboarding() {\n const isSuggestingMobileApp = ref(null);\n const pageTitle = computed(() =>\n isMobileAppSupported && unref(isSuggestingMobileApp)\n ? \"Download App\"\n : \"Update your information\",\n );\n\n const store = useStore();\n\n const isWhiteLabel = computed(() => store.getters[\"platform/isWhiteLabel\"]);\n\n async function suggestMobileApp() {\n isSuggestingMobileApp.value = isMobileAppSupported;\n\n // for supported phones a corresponding download component is displayed\n if (unref(isSuggestingMobileApp)) return;\n\n // for non mobile devices a modal is displayed and an action is required\n if (!isMobile) await openMobileAppModal();\n\n finishOnboarding();\n }\n\n async function finishOnboarding() {\n window.location = \"/dashboard\";\n }\n\n async function openMobileAppModal() {\n const modal = await openModal(MobileAppModal);\n\n return new Promise((resolve) => (modal.onclose = resolve));\n }\n\n function onOnboardReady() {\n if (unref(isWhiteLabel)) return finishOnboarding();\n\n suggestMobileApp();\n }\n\n return {\n pageTitle,\n isSuggestingMobileApp,\n finishOnboarding,\n onOnboardReady,\n };\n}\n\nfunction useUserCache(key, defaultValue = {}) {\n const store = useStore();\n const id = computed(() => store.getters[\"platform/user\"]?.id);\n const cache = useLocalStorage(key, {});\n\n function clearCache() {\n cache.value = undefined;\n }\n\n return {\n cache: computed({\n get() {\n return cache.value[id.value] || defaultValue;\n },\n set(value) {\n cache.value = {\n [id.value]: value,\n };\n },\n }),\n clearCache,\n };\n}\n</script>\n\n<style module lang=\"less\" src=\"./Onboard.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,"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}]...
|
-6043481845154234215
|
-8753422125917499098
|
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
9
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout v-bind="{ title: pageTitle }">
<MobileAppDownload
v-if="isSuggestingMobileApp"
@ready="finishOnboarding()"
/>
<div v-else :class="$style.container">
<BuildInfo />
<form>
<!-- GENERAL Section-->
<div :class="$style.sectionTitle">General</div>
<!-- Job title -->
<SelectField
v-if="showJobSelector && jobs.length > 0"
required="required"
name="jobTitleId"
fieldLabel="Select position"
v-model="form.job_title_id"
:options="jobs"
track-by="id"
:hasError="form.errors.has('job_title_id')"
:errorMessage="form.errors.get('job_title_id')"
optionLabel="name"
data-testid="job-title-selector"
/>
<!-- Timezone -->
<SelectField
required="required"
name="timezone"
optionLabel="label"
fieldLabel="Timezone"
:disabled="!timezonesReady"
v-model="form.timezone"
:options="timezonesArray"
:hasError="form.errors.has('timezone')"
:errorMessage="form.errors.get('timezone')"
track-by="tzCode"
/>
<!-- Transcription language -->
<template v-if="canRecord">
<!-- LANGUAGE Section-->
<div :class="$style.sectionTitle">
Languages spoken during calls
<tippy theme="primary" placement="bottom">
<FontAwesomeIcon :icon="faCircleInfo" :class="$style.infoIcon" />
<template #content>
<div :class="$style.textLeft">
Select all languages spoken during call.<br />
We automatically detect languages and if one isn’t identified,
it will be translated into the default language
</div>
</template>
</tippy>
</div>
<UserLocalesInputs
:hasError="
form.errors.has('language') || form.errors.has('locales')
"
:errorMessage="
form.errors.get('language') || form.errors.get('locales')
"
:buttonAttrs="{ mods: 'primary seamless' }"
v-bind="{ userLocales }"
/>
</template>
<!-- PHONE Section-->
<div
v-if="
needsToConfigurePhoneNumber ||
showDeskPhoneNumber ||
needConfigureSoftphoneNumber
"
:class="$style.sectionTitle"
>
Phone
</div>
<!-- Configure Phone number -->
<div :class="$style.panel" v-if="needsToConfigurePhoneNumber">
<PhoneField
required="required"
name="phone"
fieldLabel="Phone number"
v-model="form.phone"
tooltip="We'll use this to send you notifications and identify you."
:hasError="form.errors.has('phone')"
:errorMessage="form.errors.get('phone')"
data-testid="phone-input"
/>
</div>
<!-- Configure Desk Phone number -->
<template v-if="showDeskPhoneNumber">
<PhoneField
name="secondary_phone"
fieldLabel="Desk number"
v-model="form.secondary_phone"
:hasError="form.errors.has('secondary_phone')"
:errorMessage="form.errors.get('secondary_phone')"
data-testid="desk-phone-input"
/>
</template>
<!-- Softphone Number (SMS & Inbound number) -->
<div
:class="[$style.panel, $style.conferenceNumberPanel]"
v-if="needConfigureSoftphoneNumber"
>
<!-- Country Code selector -->
<PhoneField
:class="$style.countryCodeInput"
required="required"
name="softphone_country_code"
fieldLabel="Country"
:initialCountry="softphoneCountryCode"
:separateDialCode="true"
v-model="softphoneCountryCode"
@onCountryChange="softphoneCountryCode = $event.iso2"
/>
<!-- Area Code selector -->
<Field
v-slot="{ field, errorMessage }"
name="softphone_pattern"
:rules="`numeric|min:0|max:${softphonePattern.maxLength}`"
v-model="form.softphone_pattern"
>
<InputField
v-bind="field"
:disabled="form.busy"
fieldLabel="Area Code"
:hasError="!!errorMessage"
data-testid="area-code-input"
/>
</Field>
<!-- Displays the softphone number in a user firendly format, example: "[PHONE]" -->
<InputField
:disabled="form.busy"
readonly
name="softphone_national"
fieldLabel="SMS & Inbound"
v-model="form.softphone_national"
:hasError="form.errors.has('softphone_national')"
data-testid="softphone-input"
/>
<!-- Generate new softphone number -->
<button
type="button"
name="button"
data-testid="button-generate-softphone-number"
@click="getSoftphoneNumber"
:disabled="form.busy"
>
<font-awesome-icon :icon="faSync" />
</button>
<!-- Error messages -->
<p class="error" v-show="form.errors.has('softphone_number')">
{{ form.errors.get("softphone_number") }}
</p>
<ErrorMessage name="softphone_pattern" v-slot="{ message }">
<p class="error" v-show="!!message">
{{ message }}
</p>
</ErrorMessage>
<p class="error" v-show="form.errors.has('softphone_pattern')">
{{ form.errors.get("softphone_pattern") }}
</p>
<p class="error" v-show="form.errors.has('softphone_country_code')">
{{ form.errors.get("softphone_country_code") }}
</p>
</div>
<!-- CONNECT -->
<div
v-if="
hasBrowserExtensionInstaller || hasCrmPermission || sync.ui.visible
"
:class="$style.sectionTitle"
>
Connect/Sync settings
</div>
<!-- CRM connection status -->
<div data-testid="integrations-connections" :class="$style.actionsRows">
<template v-if="hasCrmPermission">
<span>Connect {{ capitalizedCrmName }}</span>
<!-- CRM connected -->
<template v-if="crmConnection">
<AppButton
data-testid="crm-connected"
variant="secondary"
readonly
>
<BrandLogo :name="crmDetails.name" />
Connected
</AppButton>
</template>
<!-- CRM not connected -->
<template v-else>
<AppButton
v-if="crmDetails.viaIntegrationApp"
@click="crmConnectIntegrationApp"
variant="secondary"
:busy="!crmToken"
data-testid="crm-signin"
>
<B...
|
45778
|
|
45787
|
NULL
|
0
|
2026-04-17T10:05:28.475364+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776420328475_m1.jpg...
|
PhpStorm
|
faVsco.js – ~/jiminny/app/front-end/src/components faVsco.js – ~/jiminny/app/front-end/src/components/onboard/Onboard.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
9
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout v-bind="{ title: pageTitle }">
<MobileAppDownload
v-if="isSuggestingMobileApp"
@ready="finishOnboarding()"
/>
<div v-else :class="$style.container">
<BuildInfo />
<form>
<!-- GENERAL Section-->
<div :class="$style.sectionTitle">General</div>
<!-- Job title -->
<SelectField
v-if="showJobSelector && jobs.length > 0"
required="required"
name="jobTitleId"
fieldLabel="Select position"
v-model="form.job_title_id"
:options="jobs"
track-by="id"
:hasError="form.errors.has('job_title_id')"
:errorMessage="form.errors.get('job_title_id')"
optionLabel="name"
data-testid="job-title-selector"
/>
<!-- Timezone -->
<SelectField
required="required"
name="timezone"
optionLabel="label"
fieldLabel="Timezone"
:disabled="!timezonesReady"
v-model="form.timezone"
:options="timezonesArray"
:hasError="form.errors.has('timezone')"
:errorMessage="form.errors.get('timezone')"
track-by="tzCode"
/>
<!-- Transcription language -->
<template v-if="canRecord">
<!-- LANGUAGE Section-->
<div :class="$style.sectionTitle">
Languages spoken during calls
<tippy theme="primary" placement="bottom">
<FontAwesomeIcon :icon="faCircleInfo" :class="$style.infoIcon" />
<template #content>
<div :class="$style.textLeft">
Select all languages spoken during call.<br />
We automatically detect languages and if one isn’t identified,
it will be translated into the default language
</div>
</template>
</tippy>
</div>
<UserLocalesInputs
:hasError="
form.errors.has('language') || form.errors.has('locales')
"
:errorMessage="
form.errors.get('language') || form.errors.get('locales')
"
:buttonAttrs="{ mods: 'primary seamless' }"
v-bind="{ userLocales }"
/>
</template>
<!-- PHONE Section-->
<div
v-if="
needsToConfigurePhoneNumber ||
showDeskPhoneNumber ||
needConfigureSoftphoneNumber
"
:class="$style.sectionTitle"
>
Phone
</div>
<!-- Configure Phone number -->
<div :class="$style.panel" v-if="needsToConfigurePhoneNumber">
<PhoneField
required="required"
name="phone"
fieldLabel="Phone number"
v-model="form.phone"
tooltip="We'll use this to send you notifications and identify you."
:hasError="form.errors.has('phone')"
:errorMessage="form.errors.get('phone')"
data-testid="phone-input"
/>
</div>
<!-- Configure Desk Phone number -->
<template v-if="showDeskPhoneNumber">
<PhoneField
name="secondary_phone"
fieldLabel="Desk number"
v-model="form.secondary_phone"
:hasError="form.errors.has('secondary_phone')"
:errorMessage="form.errors.get('secondary_phone')"
data-testid="desk-phone-input"
/>
</template>
<!-- Softphone Number (SMS & Inbound number) -->
<div
:class="[$style.panel, $style.conferenceNumberPanel]"
v-if="needConfigureSoftphoneNumber"
>
<!-- Country Code selector -->
<PhoneField
:class="$style.countryCodeInput"
required="required"
name="softphone_country_code"
fieldLabel="Country"
:initialCountry="softphoneCountryCode"
:separateDialCode="true"
v-model="softphoneCountryCode"
@onCountryChange="softphoneCountryCode = $event.iso2"
/>
<!-- Area Code selector -->
<Field
v-slot="{ field, errorMessage }"
name="softphone_pattern"
:rules="`numeric|min:0|max:${softphonePattern.maxLength}`"
v-model="form.softphone_pattern"
>
<InputField
v-bind="field"
:disabled="form.busy"
fieldLabel="Area Code"
:hasError="!!errorMessage"
data-testid="area-code-input"
/>
</Field>
<!-- Displays the softphone number in a user firendly format, example: "[PHONE]" -->
<InputField
:disabled="form.busy"
readonly
name="softphone_national"
fieldLabel="SMS & Inbound"
v-model="form.softphone_national"
:hasError="form.errors.has('softphone_national')"
data-testid="softphone-input"
/>
<!-- Generate new softphone number -->
<button
type="button"
name="button"
data-testid="button-generate-softphone-number"
@click="getSoftphoneNumber"
:disabled="form.busy"
>
<font-awesome-icon :icon="faSync" />
</button>
<!-- Error messages -->
<p class="error" v-show="form.errors.has('softphone_number')">
{{ form.errors.get("softphone_number") }}
</p>
<ErrorMessage name="softphone_pattern" v-slot="{ message }">
<p class="error" v-show="!!message">
{{ message }}
</p>
</ErrorMessage>
<p class="error" v-show="form.errors.has('softphone_pattern')">
{{ form.errors.get("softphone_pattern") }}
</p>
<p class="error" v-show="form.errors.has('softphone_country_code')">
{{ form.errors.get("softphone_country_code") }}
</p>
</div>
<!-- CONNECT -->
<div
v-if="
hasBrowserExtensionInstaller || hasCrmPermission || sync.ui.visible
"
:class="$style.sectionTitle"
>
Connect/Sync settings
</div>
<!-- CRM connection status -->
<div data-testid="integrations-connections" :class="$style.actionsRows">
<template v-if="hasCrmPermission">
<span>Connect {{ capitalizedCrmName }}</span>
<!-- CRM connected -->
<template v-if="crmConnection">
<AppButton
data-testid="crm-connected"
variant="secondary"
readonly
>
<BrandLogo :name="crmDetails.name" />
Connected
</AppButton>
</template>
<!-- CRM not connected -->
<template v-else>
<AppButton
v-if="crmDetails.viaIntegrationApp"
@click="crmConnectIntegrationApp"
variant="secondary"
:busy="!crmToken"
data-testid="crm-signin"
>
<B...
|
[{"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,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.016666668,"height":0.02111111},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.014583333,"height":0.025555555},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<template>\n <WelcomeLayout v-bind=\"{ title: pageTitle }\">\n <MobileAppDownload\n v-if=\"isSuggestingMobileApp\"\n @ready=\"finishOnboarding()\"\n />\n <div v-else :class=\"$style.container\">\n <BuildInfo />\n <form>\n <!-- GENERAL Section-->\n <div :class=\"$style.sectionTitle\">General</div>\n\n <!-- Job title -->\n <SelectField\n v-if=\"showJobSelector && jobs.length > 0\"\n required=\"required\"\n name=\"jobTitleId\"\n fieldLabel=\"Select position\"\n v-model=\"form.job_title_id\"\n :options=\"jobs\"\n track-by=\"id\"\n :hasError=\"form.errors.has('job_title_id')\"\n :errorMessage=\"form.errors.get('job_title_id')\"\n optionLabel=\"name\"\n data-testid=\"job-title-selector\"\n />\n\n <!-- Timezone -->\n <SelectField\n required=\"required\"\n name=\"timezone\"\n optionLabel=\"label\"\n fieldLabel=\"Timezone\"\n :disabled=\"!timezonesReady\"\n v-model=\"form.timezone\"\n :options=\"timezonesArray\"\n :hasError=\"form.errors.has('timezone')\"\n :errorMessage=\"form.errors.get('timezone')\"\n track-by=\"tzCode\"\n />\n <!-- Transcription language -->\n <template v-if=\"canRecord\">\n <!-- LANGUAGE Section-->\n <div :class=\"$style.sectionTitle\">\n Languages spoken during calls\n\n <tippy theme=\"primary\" placement=\"bottom\">\n <FontAwesomeIcon :icon=\"faCircleInfo\" :class=\"$style.infoIcon\" />\n <template #content>\n <div :class=\"$style.textLeft\">\n Select all languages spoken during call.<br />\n We automatically detect languages and if one isn’t identified,\n it will be translated into the default language\n </div>\n </template>\n </tippy>\n </div>\n <UserLocalesInputs\n :hasError=\"\n form.errors.has('language') || form.errors.has('locales')\n \"\n :errorMessage=\"\n form.errors.get('language') || form.errors.get('locales')\n \"\n :buttonAttrs=\"{ mods: 'primary seamless' }\"\n v-bind=\"{ userLocales }\"\n />\n </template>\n\n <!-- PHONE Section-->\n <div\n v-if=\"\n needsToConfigurePhoneNumber ||\n showDeskPhoneNumber ||\n needConfigureSoftphoneNumber\n \"\n :class=\"$style.sectionTitle\"\n >\n Phone\n </div>\n <!-- Configure Phone number -->\n <div :class=\"$style.panel\" v-if=\"needsToConfigurePhoneNumber\">\n <PhoneField\n required=\"required\"\n name=\"phone\"\n fieldLabel=\"Phone number\"\n v-model=\"form.phone\"\n tooltip=\"We'll use this to send you notifications and identify you.\"\n :hasError=\"form.errors.has('phone')\"\n :errorMessage=\"form.errors.get('phone')\"\n data-testid=\"phone-input\"\n />\n </div>\n <!-- Configure Desk Phone number -->\n <template v-if=\"showDeskPhoneNumber\">\n <PhoneField\n name=\"secondary_phone\"\n fieldLabel=\"Desk number\"\n v-model=\"form.secondary_phone\"\n :hasError=\"form.errors.has('secondary_phone')\"\n :errorMessage=\"form.errors.get('secondary_phone')\"\n data-testid=\"desk-phone-input\"\n />\n </template>\n <!-- Softphone Number (SMS & Inbound number) -->\n <div\n :class=\"[$style.panel, $style.conferenceNumberPanel]\"\n v-if=\"needConfigureSoftphoneNumber\"\n >\n <!-- Country Code selector -->\n <PhoneField\n :class=\"$style.countryCodeInput\"\n required=\"required\"\n name=\"softphone_country_code\"\n fieldLabel=\"Country\"\n :initialCountry=\"softphoneCountryCode\"\n :separateDialCode=\"true\"\n v-model=\"softphoneCountryCode\"\n @onCountryChange=\"softphoneCountryCode = $event.iso2\"\n />\n <!-- Area Code selector -->\n <Field\n v-slot=\"{ field, errorMessage }\"\n name=\"softphone_pattern\"\n :rules=\"`numeric|min:0|max:${softphonePattern.maxLength}`\"\n v-model=\"form.softphone_pattern\"\n >\n <InputField\n v-bind=\"field\"\n :disabled=\"form.busy\"\n fieldLabel=\"Area Code\"\n :hasError=\"!!errorMessage\"\n data-testid=\"area-code-input\"\n />\n </Field>\n <!-- Displays the softphone number in a user firendly format, example: \"(361) 345-7280\" -->\n <InputField\n :disabled=\"form.busy\"\n readonly\n name=\"softphone_national\"\n fieldLabel=\"SMS & Inbound\"\n v-model=\"form.softphone_national\"\n :hasError=\"form.errors.has('softphone_national')\"\n data-testid=\"softphone-input\"\n />\n <!-- Generate new softphone number -->\n <button\n type=\"button\"\n name=\"button\"\n data-testid=\"button-generate-softphone-number\"\n @click=\"getSoftphoneNumber\"\n :disabled=\"form.busy\"\n >\n <font-awesome-icon :icon=\"faSync\" />\n </button>\n <!-- Error messages -->\n <p class=\"error\" v-show=\"form.errors.has('softphone_number')\">\n {{ form.errors.get(\"softphone_number\") }}\n </p>\n <ErrorMessage name=\"softphone_pattern\" v-slot=\"{ message }\">\n <p class=\"error\" v-show=\"!!message\">\n {{ message }}\n </p>\n </ErrorMessage>\n <p class=\"error\" v-show=\"form.errors.has('softphone_pattern')\">\n {{ form.errors.get(\"softphone_pattern\") }}\n </p>\n <p class=\"error\" v-show=\"form.errors.has('softphone_country_code')\">\n {{ form.errors.get(\"softphone_country_code\") }}\n </p>\n </div>\n\n <!-- CONNECT -->\n <div\n v-if=\"\n hasBrowserExtensionInstaller || hasCrmPermission || sync.ui.visible\n \"\n :class=\"$style.sectionTitle\"\n >\n Connect/Sync settings\n </div>\n <!-- CRM connection status -->\n <div data-testid=\"integrations-connections\" :class=\"$style.actionsRows\">\n <template v-if=\"hasCrmPermission\">\n <span>Connect {{ capitalizedCrmName }}</span>\n <!-- CRM connected -->\n <template v-if=\"crmConnection\">\n <AppButton\n data-testid=\"crm-connected\"\n variant=\"secondary\"\n readonly\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Connected\n </AppButton>\n </template>\n <!-- CRM not connected -->\n <template v-else>\n <AppButton\n v-if=\"crmDetails.viaIntegrationApp\"\n @click=\"crmConnectIntegrationApp\"\n variant=\"secondary\"\n :busy=\"!crmToken\"\n data-testid=\"crm-signin\"\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Sign in with {{ capitalizedCrmName }}\n </AppButton>\n <AppButton\n v-else\n :href=\"crmConnectUrl\"\n variant=\"secondary\"\n data-testid=\"crm-signin\"\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Sign in with {{ capitalizedCrmName }}\n </AppButton>\n </template>\n </template>\n\n <template v-if=\"sync.ui.visible\">\n <span data-testid=\"sync-text\"\n ><span\n >{{ sync.ui.text\n }}<span v-if=\"sync.ui.required\" :class=\"$style.asterisk\"></span\n ></span>\n <tippy v-if=\"sync.ui.tip\" theme=\"primary\" placement=\"bottom\">\n <FontAwesomeIcon\n :icon=\"faCircleInfo\"\n :class=\"$style.infoIcon\"\n />\n <template #content>\n <div :class=\"$style.textLeft\">\n We will use your email account to give you a more complete\n view of activity related to your customers. Only emails with\n participants connected to an external CRM record are stored\n and displayed.\n </div>\n </template>\n </tippy>\n </span>\n <AppButton\n v-if=\"sync.ui.completed\"\n readonly\n variant=\"secondary\"\n data-testid=\"sync-completed\"\n >\n <BrandLogo :name=\"sync.provider\" />\n Completed\n </AppButton>\n <AppButton\n v-else\n variant=\"secondary\"\n :href=\"sync.ui.href\"\n busy=\"auto\"\n data-testid=\"sync-signin\"\n >\n <BrandLogo :name=\"sync.provider\" />\n Sign in with\n {{ sync.provider == \"office\" ? \"Office 365\" : \"Google\" }}\n </AppButton>\n </template>\n\n <!-- Sidekick Extension Installation status -->\n <BrowserExtensionInstaller\n v-if=\"hasBrowserExtensionInstaller\"\n :extension-id=\"chromeExtensionId\"\n as=\"template\"\n >\n <template #notInstalled=\"{ startChecking }\">\n <span>Jiminny Sidekick Extension</span>\n <AppButton\n :href=\"chromeWebstoreUrl\"\n mods=\"primary outline\"\n @click=\"startChecking\"\n >\n Add to Chrome\n </AppButton>\n </template>\n <template #installed>\n <span>Jiminny Sidekick Extension</span>\n <StatusBadge preset=\"success\">Added</StatusBadge>\n </template>\n </BrowserExtensionInstaller>\n </div>\n </form>\n <!-- Submit Form Button -->\n <AppButton\n @click=\"update\"\n :disabled=\"submitDisabled\"\n variant=\"primary\"\n :class=\"$style.submitButton\"\n mods=\"lg\"\n >\n <template v-if=\"form.busy\">\n <i class=\"fa fa-btn fa-spinner fa-spin fa-icon-padding\"></i>One\n Moment...\n </template>\n <template v-else>\n Let's Get Started!\n <font-awesome-icon\n :icon=\"faLongArrowRight\"\n :class=\"$style.submitIcon\"\n />\n </template>\n </AppButton>\n </div>\n </WelcomeLayout>\n</template>\n\n<script>\n// Icons\nimport { FontAwesomeIcon } from \"@fortawesome/vue-fontawesome\";\nimport { faCheckCircle, faSync } from \"@fortawesome/pro-regular-svg-icons\";\nimport {\n faCircleInfo,\n faLongArrowRight,\n} from \"@fortawesome/pro-light-svg-icons\";\nimport { faSalesforce } from \"@fortawesome/free-brands-svg-icons/faSalesforce\";\n// Components\nimport BrowserExtensionInstaller from \"@/components/shared/BrowserExtensionInstaller/BrowserExtensionInstaller.vue\";\nimport PhoneField from \"@/components/Settings/shared/FormElements/PhoneField.vue\";\nimport SelectField from \"@/components/Settings/shared/FormElements/SelectField.vue\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport InputField from \"@/components/Settings/shared/FormElements/InputField.vue\";\nimport AppButton from \"@/components/shared/Buttons/AppButton.vue\";\nimport StatusBadge from \"@/components/shared/StatusBadge/StatusBadge.vue\";\nimport BrandLogo from \"@/components/shared/BrandLogo.vue\";\nimport MobileAppModal from \"@/components/shared/modals/MobileAppModal.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport MobileAppDownload from \"@/components/onboard/MobileAppDownload.vue\";\n// Utils\nimport { JiminnyForm, localStorage } from \"window\";\nimport axios from \"axios\";\nimport env from \"@/utils/env\";\nimport useragent from \"@/utils/useragent\";\nimport { isMobileAppSupported, isMobile } from \"@/utils/mobileApp\";\nimport useAuthState from \"@/composables/useAuthState\";\nimport { useForm, ErrorMessage, Field, defineRule } from \"vee-validate\";\nimport DOMPurify from \"dompurify\";\nimport useTimezonesCountriesHelper from \"@/composables/useTimezonesCountriesHelper\";\nimport { computed, ref, onMounted, watch, unref, reactive } from \"vue\";\nimport { openModal } from \"jenesius-vue-modal\";\nimport { useStore } from \"vuex\";\nimport * as Sentry from \"@sentry/vue\";\nimport { numeric, min, max } from \"@vee-validate/rules\";\nimport { useLocalStorage } from \"@vueuse/core\";\nimport { showSnackbarError, normalizeError } from \"@/utils\";\nimport { Tippy } from \"vue-tippy\";\nimport intlTelInput from \"intl-tel-input\";\nimport { useUserLocalesSettings } from \"@/composables/useUserLocalesSettings\";\nimport UserLocalesInputs from \"@/components/shared/UserLocalesInputs/UserLocalesInputs.vue\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\nimport useProvidersSyncState from \"./useProvidersSyncState\";\n\ndefineRule(\"numeric\", numeric);\ndefineRule(\"min\", min);\ndefineRule(\"max\", max);\n\nexport default {\n name: \"OnboardPage\",\n components: {\n BuildInfo,\n FontAwesomeIcon,\n BrowserExtensionInstaller,\n PhoneField,\n SelectField,\n InputField,\n Field,\n ErrorMessage,\n AppButton,\n StatusBadge,\n BrandLogo,\n WelcomeLayout,\n MobileAppDownload,\n Tippy,\n UserLocalesInputs,\n },\n setup() {\n const { crmRequired, crmConnection, crmName, crmDetails, backendErrors } =\n window.onboardData;\n\n const countryCodes = [\"CA\", \"US\"];\n const chromeExtensionId = env(\"CHROME_WEB_STORE_EXT_ID\");\n const inChrome = useragent.browser.name === \"Chrome\";\n\n // Store getters\n const store = useStore();\n const user = computed(() => store.getters[\"platform/user\"]);\n const team = computed(() => store.getters[\"platform/team\"]);\n // Actions\n const updateUser = store.dispatch.bind(store, \"platform/updateUser\");\n\n const sync = useProvidersSyncState();\n\n let unwatch = null;\n\n const { validate } = useForm();\n const { canAny } = useAuthState();\n\n const { timezonesArray, initTimezones, timezonesReady } =\n useTimezonesCountriesHelper();\n\n initTimezones();\n\n const finishOnboardingUtils = useFinishOnboarding();\n\n const jobs = ref([]);\n\n const { cache: cachedForm, clearCache: clearCachedForm } =\n useUserCache(\"onboard-form\");\n\n const userLocales = reactive(\n useUserLocalesSettings({\n modelMinLength: 1,\n cachedUserData: cachedForm.value,\n }),\n );\n\n const form = ref(\n new JiminnyForm({\n phone: \"\", // mobile phone\n job_title_id: \"\",\n secondary_phone: \"\", // secondary phone if softphoneSecondaryRoute = 'work'\n country_code: \"\",\n softphone_pattern: null,\n softphone_number: \"\", // telephony provider i.e. sms/softphone number\n softphone_national: \"\", // national formatted sms/softphone number\n timezone: \"\",\n ...cachedForm.value,\n }),\n );\n\n const countries = computed(() => {\n return intlTelInput.getCountryData().map((data) => {\n return {\n id: data.iso2,\n text: `+${data.dialCode}`,\n };\n });\n });\n\n const softphonePattern = computed(() => {\n const countryCode = form.value[\"softphone_country_code\"]?.toUpperCase();\n\n if (!countryCode) return {};\n\n return countryCodes.includes(countryCode)\n ? {\n maxLength: 3,\n helper: \"Please select your area code e.g. 917\",\n }\n : {\n maxLength: 5,\n helper: \"Your local area code e.g. 0161\",\n };\n });\n const chromeWebstoreUrl = ref(null);\n const softphoneCountryCode = ref(\n form.value[\"softphone_country_code\"] || \"\",\n );\n\n watch(\n () => softphoneCountryCode.value,\n (newValue) => {\n onSoftphoneCountryCodeChange(newValue);\n },\n );\n\n // Computed Props\n const userHasTelephonyPermission = computed(() => canAny([\"dial\", \"sms\"]));\n const capitalizedCrmName = crmDetails.displayName;\n const needConfigureSoftphoneNumber = computed(\n () => !user.value.softphoneNumber && userHasTelephonyPermission.value,\n );\n const needsToConfigurePhoneNumber = computed(\n () => user.value.needsToConfigurePhoneNumber,\n );\n const hasBrowserExtensionInstaller = computed(\n () =>\n canAny([\"record.meeting\", \"dial\"]) &&\n user.value.hasSidekickEnabled &&\n inChrome &&\n chromeExtensionId &&\n chromeWebstoreUrl.value,\n );\n const hasCrmPermission = computed(\n () => user.value.crmRequired && crmRequired,\n );\n const crmConnectUrl = computed(\n () => `/auth/redirect/${DOMPurify.sanitize(crmDetails.name)}`,\n );\n\n /** BEGIN: IntegrationApp related functionality */\n\n const crmToken = ref(null);\n\n const crmConnectIntegrationApp = async function () {\n const integrationApp = new IntegrationAppClient({\n token: crmToken.value,\n });\n\n const connection = await integrationApp\n .integration(crmDetails.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n });\n\n if (!connection || connection.connected === false) {\n showSnackbarError(\n \"A connection with your CRM could not be established\",\n );\n return;\n }\n\n try {\n const saveRequest = await axios.post(\"/api/v1/integration-app-connect\");\n\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n return location.reload();\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n };\n\n const prepareIntegrationAppConnection = async function () {\n if (crmDetails?.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n crmToken.value = response.data.token;\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 if (crmRequired) {\n // Prepare the crm token only if the CRM is required\n prepareIntegrationAppConnection();\n }\n /** END: IntegrationApp related functionality */\n\n const showJobSelector = computed(() => !user.value.job);\n const showDeskPhoneNumber = computed(\n () =>\n needsToConfigurePhoneNumber.value &&\n !user.value.secondaryPhone &&\n team.value.softphoneRoutingPreference,\n );\n // Only users who can record calls\n const canRecord = computed(() => canAny([\"record.meeting\", \"dial\"]));\n\n const submitDisabled = computed(() => {\n if (unref(form).busy) return true;\n return sync.anyNeedAuthorization;\n });\n\n // Methods\n function showErrors() {\n if (backendErrors.length > 0) {\n backendErrors.forEach((error) =>\n showSnackbarError(error, undefined, undefined, false),\n );\n }\n }\n\n async function getSoftphoneNumber() {\n form.value.errors.forget();\n\n if (!form.value[\"softphone_country_code\"]) {\n return form.value.errors.set({\n softphone_country_code: [\"The country field is required.\"],\n });\n }\n\n const { valid } = await validate();\n\n if (!valid) {\n return;\n }\n\n try {\n const params = {\n pattern: form.value[\"softphone_pattern\"],\n country_code: form.value[\"softphone_country_code\"].toUpperCase(),\n capabilities: [\"voice\", \"sms\"],\n };\n\n /**\n * Exemplary response:\n * {\n * \"number\": \"+447782581047\",\n * \"national\": \"07782 581322\", // national is formatted number\n * \"pattern\": \"7782\" // patrten is area code\n * }\n */\n const { data } = await axios.get(\"/api/v1/phone-numbers\", {\n params,\n });\n\n form.value[\"softphone_number\"] = data?.number || \"\";\n form.value[\"softphone_national\"] = data?.national || \"\";\n form.value[\"softphone_pattern\"] = data?.pattern || null;\n } catch (errors) {\n form.value[\"softphone_number\"] = \"\";\n form.value[\"softphone_national\"] = \"\";\n form.value[\"softphone_pattern\"] = null;\n\n handleErrors(errors.response.data);\n }\n }\n\n function handleErrors(errors) {\n if (Object.prototype.hasOwnProperty.call(errors, \"errors\")) {\n form.value.errors.set(errors.errors);\n } else {\n form.value.errors.set(errors);\n }\n }\n\n async function getJobTitles() {\n const response = await axios.get(\n `/api/v1/organizations/${team.value.id}/job-titles`,\n );\n\n jobs.value = response.data;\n }\n\n function preselectSoftphoneCountryCode() {\n form.value[\"softphone_country_code\"] ||= selectedCountry()?.id || \"\";\n }\n\n function selectedCountry() {\n const countryCode = user.value.countryCode?.toLowerCase() || \"\";\n\n if (!countryCode) return \"\";\n\n return countries.value.find((country) => country.id === countryCode);\n }\n\n function onSoftphoneCountryCodeChange(countryCode) {\n form.value[\"softphone_country_code\"] = countryCode;\n form.value[\"softphone_pattern\"] = null;\n form.value[\"softphone_national\"] = \"\";\n form.value[\"softphone_number\"] = \"\";\n getSoftphoneNumber();\n }\n\n async function update() {\n try {\n form.value.busy = true;\n const payload = form.value;\n payload[\"country_code\"] = form.value[\"country_code\"].toUpperCase();\n Object.assign(payload, unref(userLocales.formData));\n\n await axios.post(\"/onboard\", payload);\n\n form.value.errors.forget();\n localStorage.setItem(\"showVerifyCallerIdModal\", true);\n updateUser();\n clearCachedForm();\n finishOnboardingUtils.onOnboardReady();\n } catch (errors) {\n const response = errors.response.data;\n const errorData = Object.hasOwn(response, \"errors\")\n ? errors.response.data.errors\n : errors.response.data;\n\n form.value.errors.set(errorData);\n if (errorData.sync_email) showSnackbarError(errorData.sync_email[0]);\n if (errorData.sync_calendar)\n showSnackbarError(errorData.sync_calendar[0]);\n } finally {\n form.value.busy = false;\n }\n }\n\n function resetLanguageError(value) {\n if (value) {\n form.value.errors.set(\"language\", \"\");\n }\n }\n\n // preselect timezone\n unwatch = watch(\n () => timezonesReady.value,\n (ready) => {\n // user already selected a timezone\n if (form.value.timezone) return;\n if (!ready) return;\n\n const browserTimezone =\n Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n const hasBrowserTimezone = timezonesArray.value.some(\n ({ tzCode }) => tzCode === browserTimezone,\n );\n\n if (hasBrowserTimezone) {\n form.value[\"timezone\"] = browserTimezone;\n } else {\n // Fallback to the initial user timezone (which should match the team timezone)\n form.value[\"timezone\"] = user.value?.timezone || \"\";\n\n const errorMessage = `Browser timezone doesn't match any of our timezones. Will fallback to the initial user timezone (which should match the team's timezone). Browser timezone: \"${browserTimezone}\"`;\n\n console.error(errorMessage);\n Sentry.captureException(new Error(errorMessage));\n }\n\n // Stop watching\n if (unwatch) {\n unwatch();\n }\n },\n { immediate: true },\n );\n\n onMounted(() => {\n showErrors();\n\n if (needConfigureSoftphoneNumber.value) {\n preselectSoftphoneCountryCode();\n if (!form.value.softphone_number) getSoftphoneNumber();\n }\n\n form.value[\"phone\"] ||= user.value.phone || \"\";\n form.value[\"secondary_phone\"] ||= user.value.secondaryPhone || \"\";\n form.value[\"softphone_number\"] ||= user.value.softphoneNumber || \"\";\n form.value[\"job_title_id\"] ||= user.value.job?.id || false;\n\n if (canRecord.value) {\n form.value[\"language\"] ||= \"\";\n }\n\n if (showJobSelector.value) getJobTitles();\n\n const webStoreUrlLink = document.querySelector(\n \"link[rel=chrome-webstore-item]\",\n );\n\n if (webStoreUrlLink) {\n chromeWebstoreUrl.value = DOMPurify.sanitize(\n webStoreUrlLink.getAttribute(\"href\"),\n );\n }\n });\n\n watch(\n form,\n (form) => {\n cachedForm.value = form;\n },\n { deep: true },\n );\n\n watch(\n () => userLocales.formData,\n (userLocales) => {\n cachedForm.value = {\n ...cachedForm.value,\n ...userLocales,\n };\n },\n );\n\n return {\n submitDisabled,\n ...finishOnboardingUtils,\n userLocales,\n sync,\n clearCachedForm,\n validate,\n faCircleInfo,\n faLongArrowRight,\n faSalesforce,\n faCheckCircle,\n faSync,\n timezonesArray,\n timezonesReady,\n crmConnection,\n crmName,\n crmDetails,\n softphoneCountryCode,\n countries,\n softphonePattern,\n chromeWebstoreUrl,\n chromeExtensionId,\n user,\n capitalizedCrmName,\n needConfigureSoftphoneNumber,\n needsToConfigurePhoneNumber,\n hasBrowserExtensionInstaller,\n hasCrmPermission,\n crmConnectUrl,\n crmConnectIntegrationApp,\n showJobSelector,\n showDeskPhoneNumber,\n canRecord,\n getSoftphoneNumber,\n update,\n resetLanguageError,\n form,\n jobs,\n crmToken,\n };\n },\n};\n\nfunction useFinishOnboarding() {\n const isSuggestingMobileApp = ref(null);\n const pageTitle = computed(() =>\n isMobileAppSupported && unref(isSuggestingMobileApp)\n ? \"Download App\"\n : \"Update your information\",\n );\n\n const store = useStore();\n\n const isWhiteLabel = computed(() => store.getters[\"platform/isWhiteLabel\"]);\n\n async function suggestMobileApp() {\n isSuggestingMobileApp.value = isMobileAppSupported;\n\n // for supported phones a corresponding download component is displayed\n if (unref(isSuggestingMobileApp)) return;\n\n // for non mobile devices a modal is displayed and an action is required\n if (!isMobile) await openMobileAppModal();\n\n finishOnboarding();\n }\n\n async function finishOnboarding() {\n window.location = \"/dashboard\";\n }\n\n async function openMobileAppModal() {\n const modal = await openModal(MobileAppModal);\n\n return new Promise((resolve) => (modal.onclose = resolve));\n }\n\n function onOnboardReady() {\n if (unref(isWhiteLabel)) return finishOnboarding();\n\n suggestMobileApp();\n }\n\n return {\n pageTitle,\n isSuggestingMobileApp,\n finishOnboarding,\n onOnboardReady,\n };\n}\n\nfunction useUserCache(key, defaultValue = {}) {\n const store = useStore();\n const id = computed(() => store.getters[\"platform/user\"]?.id);\n const cache = useLocalStorage(key, {});\n\n function clearCache() {\n cache.value = undefined;\n }\n\n return {\n cache: computed({\n get() {\n return cache.value[id.value] || defaultValue;\n },\n set(value) {\n cache.value = {\n [id.value]: value,\n };\n },\n }),\n clearCache,\n };\n}\n</script>\n\n<style module lang=\"less\" src=\"./Onboard.less\"></style>","depth":4,"value":"<template>\n <WelcomeLayout v-bind=\"{ title: pageTitle }\">\n <MobileAppDownload\n v-if=\"isSuggestingMobileApp\"\n @ready=\"finishOnboarding()\"\n />\n <div v-else :class=\"$style.container\">\n <BuildInfo />\n <form>\n <!-- GENERAL Section-->\n <div :class=\"$style.sectionTitle\">General</div>\n\n <!-- Job title -->\n <SelectField\n v-if=\"showJobSelector && jobs.length > 0\"\n required=\"required\"\n name=\"jobTitleId\"\n fieldLabel=\"Select position\"\n v-model=\"form.job_title_id\"\n :options=\"jobs\"\n track-by=\"id\"\n :hasError=\"form.errors.has('job_title_id')\"\n :errorMessage=\"form.errors.get('job_title_id')\"\n optionLabel=\"name\"\n data-testid=\"job-title-selector\"\n />\n\n <!-- Timezone -->\n <SelectField\n required=\"required\"\n name=\"timezone\"\n optionLabel=\"label\"\n fieldLabel=\"Timezone\"\n :disabled=\"!timezonesReady\"\n v-model=\"form.timezone\"\n :options=\"timezonesArray\"\n :hasError=\"form.errors.has('timezone')\"\n :errorMessage=\"form.errors.get('timezone')\"\n track-by=\"tzCode\"\n />\n <!-- Transcription language -->\n <template v-if=\"canRecord\">\n <!-- LANGUAGE Section-->\n <div :class=\"$style.sectionTitle\">\n Languages spoken during calls\n\n <tippy theme=\"primary\" placement=\"bottom\">\n <FontAwesomeIcon :icon=\"faCircleInfo\" :class=\"$style.infoIcon\" />\n <template #content>\n <div :class=\"$style.textLeft\">\n Select all languages spoken during call.<br />\n We automatically detect languages and if one isn’t identified,\n it will be translated into the default language\n </div>\n </template>\n </tippy>\n </div>\n <UserLocalesInputs\n :hasError=\"\n form.errors.has('language') || form.errors.has('locales')\n \"\n :errorMessage=\"\n form.errors.get('language') || form.errors.get('locales')\n \"\n :buttonAttrs=\"{ mods: 'primary seamless' }\"\n v-bind=\"{ userLocales }\"\n />\n </template>\n\n <!-- PHONE Section-->\n <div\n v-if=\"\n needsToConfigurePhoneNumber ||\n showDeskPhoneNumber ||\n needConfigureSoftphoneNumber\n \"\n :class=\"$style.sectionTitle\"\n >\n Phone\n </div>\n <!-- Configure Phone number -->\n <div :class=\"$style.panel\" v-if=\"needsToConfigurePhoneNumber\">\n <PhoneField\n required=\"required\"\n name=\"phone\"\n fieldLabel=\"Phone number\"\n v-model=\"form.phone\"\n tooltip=\"We'll use this to send you notifications and identify you.\"\n :hasError=\"form.errors.has('phone')\"\n :errorMessage=\"form.errors.get('phone')\"\n data-testid=\"phone-input\"\n />\n </div>\n <!-- Configure Desk Phone number -->\n <template v-if=\"showDeskPhoneNumber\">\n <PhoneField\n name=\"secondary_phone\"\n fieldLabel=\"Desk number\"\n v-model=\"form.secondary_phone\"\n :hasError=\"form.errors.has('secondary_phone')\"\n :errorMessage=\"form.errors.get('secondary_phone')\"\n data-testid=\"desk-phone-input\"\n />\n </template>\n <!-- Softphone Number (SMS & Inbound number) -->\n <div\n :class=\"[$style.panel, $style.conferenceNumberPanel]\"\n v-if=\"needConfigureSoftphoneNumber\"\n >\n <!-- Country Code selector -->\n <PhoneField\n :class=\"$style.countryCodeInput\"\n required=\"required\"\n name=\"softphone_country_code\"\n fieldLabel=\"Country\"\n :initialCountry=\"softphoneCountryCode\"\n :separateDialCode=\"true\"\n v-model=\"softphoneCountryCode\"\n @onCountryChange=\"softphoneCountryCode = $event.iso2\"\n />\n <!-- Area Code selector -->\n <Field\n v-slot=\"{ field, errorMessage }\"\n name=\"softphone_pattern\"\n :rules=\"`numeric|min:0|max:${softphonePattern.maxLength}`\"\n v-model=\"form.softphone_pattern\"\n >\n <InputField\n v-bind=\"field\"\n :disabled=\"form.busy\"\n fieldLabel=\"Area Code\"\n :hasError=\"!!errorMessage\"\n data-testid=\"area-code-input\"\n />\n </Field>\n <!-- Displays the softphone number in a user firendly format, example: \"(361) 345-7280\" -->\n <InputField\n :disabled=\"form.busy\"\n readonly\n name=\"softphone_national\"\n fieldLabel=\"SMS & Inbound\"\n v-model=\"form.softphone_national\"\n :hasError=\"form.errors.has('softphone_national')\"\n data-testid=\"softphone-input\"\n />\n <!-- Generate new softphone number -->\n <button\n type=\"button\"\n name=\"button\"\n data-testid=\"button-generate-softphone-number\"\n @click=\"getSoftphoneNumber\"\n :disabled=\"form.busy\"\n >\n <font-awesome-icon :icon=\"faSync\" />\n </button>\n <!-- Error messages -->\n <p class=\"error\" v-show=\"form.errors.has('softphone_number')\">\n {{ form.errors.get(\"softphone_number\") }}\n </p>\n <ErrorMessage name=\"softphone_pattern\" v-slot=\"{ message }\">\n <p class=\"error\" v-show=\"!!message\">\n {{ message }}\n </p>\n </ErrorMessage>\n <p class=\"error\" v-show=\"form.errors.has('softphone_pattern')\">\n {{ form.errors.get(\"softphone_pattern\") }}\n </p>\n <p class=\"error\" v-show=\"form.errors.has('softphone_country_code')\">\n {{ form.errors.get(\"softphone_country_code\") }}\n </p>\n </div>\n\n <!-- CONNECT -->\n <div\n v-if=\"\n hasBrowserExtensionInstaller || hasCrmPermission || sync.ui.visible\n \"\n :class=\"$style.sectionTitle\"\n >\n Connect/Sync settings\n </div>\n <!-- CRM connection status -->\n <div data-testid=\"integrations-connections\" :class=\"$style.actionsRows\">\n <template v-if=\"hasCrmPermission\">\n <span>Connect {{ capitalizedCrmName }}</span>\n <!-- CRM connected -->\n <template v-if=\"crmConnection\">\n <AppButton\n data-testid=\"crm-connected\"\n variant=\"secondary\"\n readonly\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Connected\n </AppButton>\n </template>\n <!-- CRM not connected -->\n <template v-else>\n <AppButton\n v-if=\"crmDetails.viaIntegrationApp\"\n @click=\"crmConnectIntegrationApp\"\n variant=\"secondary\"\n :busy=\"!crmToken\"\n data-testid=\"crm-signin\"\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Sign in with {{ capitalizedCrmName }}\n </AppButton>\n <AppButton\n v-else\n :href=\"crmConnectUrl\"\n variant=\"secondary\"\n data-testid=\"crm-signin\"\n >\n <BrandLogo :name=\"crmDetails.name\" />\n Sign in with {{ capitalizedCrmName }}\n </AppButton>\n </template>\n </template>\n\n <template v-if=\"sync.ui.visible\">\n <span data-testid=\"sync-text\"\n ><span\n >{{ sync.ui.text\n }}<span v-if=\"sync.ui.required\" :class=\"$style.asterisk\"></span\n ></span>\n <tippy v-if=\"sync.ui.tip\" theme=\"primary\" placement=\"bottom\">\n <FontAwesomeIcon\n :icon=\"faCircleInfo\"\n :class=\"$style.infoIcon\"\n />\n <template #content>\n <div :class=\"$style.textLeft\">\n We will use your email account to give you a more complete\n view of activity related to your customers. Only emails with\n participants connected to an external CRM record are stored\n and displayed.\n </div>\n </template>\n </tippy>\n </span>\n <AppButton\n v-if=\"sync.ui.completed\"\n readonly\n variant=\"secondary\"\n data-testid=\"sync-completed\"\n >\n <BrandLogo :name=\"sync.provider\" />\n Completed\n </AppButton>\n <AppButton\n v-else\n variant=\"secondary\"\n :href=\"sync.ui.href\"\n busy=\"auto\"\n data-testid=\"sync-signin\"\n >\n <BrandLogo :name=\"sync.provider\" />\n Sign in with\n {{ sync.provider == \"office\" ? \"Office 365\" : \"Google\" }}\n </AppButton>\n </template>\n\n <!-- Sidekick Extension Installation status -->\n <BrowserExtensionInstaller\n v-if=\"hasBrowserExtensionInstaller\"\n :extension-id=\"chromeExtensionId\"\n as=\"template\"\n >\n <template #notInstalled=\"{ startChecking }\">\n <span>Jiminny Sidekick Extension</span>\n <AppButton\n :href=\"chromeWebstoreUrl\"\n mods=\"primary outline\"\n @click=\"startChecking\"\n >\n Add to Chrome\n </AppButton>\n </template>\n <template #installed>\n <span>Jiminny Sidekick Extension</span>\n <StatusBadge preset=\"success\">Added</StatusBadge>\n </template>\n </BrowserExtensionInstaller>\n </div>\n </form>\n <!-- Submit Form Button -->\n <AppButton\n @click=\"update\"\n :disabled=\"submitDisabled\"\n variant=\"primary\"\n :class=\"$style.submitButton\"\n mods=\"lg\"\n >\n <template v-if=\"form.busy\">\n <i class=\"fa fa-btn fa-spinner fa-spin fa-icon-padding\"></i>One\n Moment...\n </template>\n <template v-else>\n Let's Get Started!\n <font-awesome-icon\n :icon=\"faLongArrowRight\"\n :class=\"$style.submitIcon\"\n />\n </template>\n </AppButton>\n </div>\n </WelcomeLayout>\n</template>\n\n<script>\n// Icons\nimport { FontAwesomeIcon } from \"@fortawesome/vue-fontawesome\";\nimport { faCheckCircle, faSync } from \"@fortawesome/pro-regular-svg-icons\";\nimport {\n faCircleInfo,\n faLongArrowRight,\n} from \"@fortawesome/pro-light-svg-icons\";\nimport { faSalesforce } from \"@fortawesome/free-brands-svg-icons/faSalesforce\";\n// Components\nimport BrowserExtensionInstaller from \"@/components/shared/BrowserExtensionInstaller/BrowserExtensionInstaller.vue\";\nimport PhoneField from \"@/components/Settings/shared/FormElements/PhoneField.vue\";\nimport SelectField from \"@/components/Settings/shared/FormElements/SelectField.vue\";\nimport BuildInfo from \"@/components/layout/BuildInfo/BuildInfo.vue\";\nimport InputField from \"@/components/Settings/shared/FormElements/InputField.vue\";\nimport AppButton from \"@/components/shared/Buttons/AppButton.vue\";\nimport StatusBadge from \"@/components/shared/StatusBadge/StatusBadge.vue\";\nimport BrandLogo from \"@/components/shared/BrandLogo.vue\";\nimport MobileAppModal from \"@/components/shared/modals/MobileAppModal.vue\";\nimport WelcomeLayout from \"@/components/layout/WelcomeLayout/WelcomeLayout.vue\";\nimport MobileAppDownload from \"@/components/onboard/MobileAppDownload.vue\";\n// Utils\nimport { JiminnyForm, localStorage } from \"window\";\nimport axios from \"axios\";\nimport env from \"@/utils/env\";\nimport useragent from \"@/utils/useragent\";\nimport { isMobileAppSupported, isMobile } from \"@/utils/mobileApp\";\nimport useAuthState from \"@/composables/useAuthState\";\nimport { useForm, ErrorMessage, Field, defineRule } from \"vee-validate\";\nimport DOMPurify from \"dompurify\";\nimport useTimezonesCountriesHelper from \"@/composables/useTimezonesCountriesHelper\";\nimport { computed, ref, onMounted, watch, unref, reactive } from \"vue\";\nimport { openModal } from \"jenesius-vue-modal\";\nimport { useStore } from \"vuex\";\nimport * as Sentry from \"@sentry/vue\";\nimport { numeric, min, max } from \"@vee-validate/rules\";\nimport { useLocalStorage } from \"@vueuse/core\";\nimport { showSnackbarError, normalizeError } from \"@/utils\";\nimport { Tippy } from \"vue-tippy\";\nimport intlTelInput from \"intl-tel-input\";\nimport { useUserLocalesSettings } from \"@/composables/useUserLocalesSettings\";\nimport UserLocalesInputs from \"@/components/shared/UserLocalesInputs/UserLocalesInputs.vue\";\nimport { IntegrationAppClient } from \"@integration-app/sdk\";\nimport useProvidersSyncState from \"./useProvidersSyncState\";\n\ndefineRule(\"numeric\", numeric);\ndefineRule(\"min\", min);\ndefineRule(\"max\", max);\n\nexport default {\n name: \"OnboardPage\",\n components: {\n BuildInfo,\n FontAwesomeIcon,\n BrowserExtensionInstaller,\n PhoneField,\n SelectField,\n InputField,\n Field,\n ErrorMessage,\n AppButton,\n StatusBadge,\n BrandLogo,\n WelcomeLayout,\n MobileAppDownload,\n Tippy,\n UserLocalesInputs,\n },\n setup() {\n const { crmRequired, crmConnection, crmName, crmDetails, backendErrors } =\n window.onboardData;\n\n const countryCodes = [\"CA\", \"US\"];\n const chromeExtensionId = env(\"CHROME_WEB_STORE_EXT_ID\");\n const inChrome = useragent.browser.name === \"Chrome\";\n\n // Store getters\n const store = useStore();\n const user = computed(() => store.getters[\"platform/user\"]);\n const team = computed(() => store.getters[\"platform/team\"]);\n // Actions\n const updateUser = store.dispatch.bind(store, \"platform/updateUser\");\n\n const sync = useProvidersSyncState();\n\n let unwatch = null;\n\n const { validate } = useForm();\n const { canAny } = useAuthState();\n\n const { timezonesArray, initTimezones, timezonesReady } =\n useTimezonesCountriesHelper();\n\n initTimezones();\n\n const finishOnboardingUtils = useFinishOnboarding();\n\n const jobs = ref([]);\n\n const { cache: cachedForm, clearCache: clearCachedForm } =\n useUserCache(\"onboard-form\");\n\n const userLocales = reactive(\n useUserLocalesSettings({\n modelMinLength: 1,\n cachedUserData: cachedForm.value,\n }),\n );\n\n const form = ref(\n new JiminnyForm({\n phone: \"\", // mobile phone\n job_title_id: \"\",\n secondary_phone: \"\", // secondary phone if softphoneSecondaryRoute = 'work'\n country_code: \"\",\n softphone_pattern: null,\n softphone_number: \"\", // telephony provider i.e. sms/softphone number\n softphone_national: \"\", // national formatted sms/softphone number\n timezone: \"\",\n ...cachedForm.value,\n }),\n );\n\n const countries = computed(() => {\n return intlTelInput.getCountryData().map((data) => {\n return {\n id: data.iso2,\n text: `+${data.dialCode}`,\n };\n });\n });\n\n const softphonePattern = computed(() => {\n const countryCode = form.value[\"softphone_country_code\"]?.toUpperCase();\n\n if (!countryCode) return {};\n\n return countryCodes.includes(countryCode)\n ? {\n maxLength: 3,\n helper: \"Please select your area code e.g. 917\",\n }\n : {\n maxLength: 5,\n helper: \"Your local area code e.g. 0161\",\n };\n });\n const chromeWebstoreUrl = ref(null);\n const softphoneCountryCode = ref(\n form.value[\"softphone_country_code\"] || \"\",\n );\n\n watch(\n () => softphoneCountryCode.value,\n (newValue) => {\n onSoftphoneCountryCodeChange(newValue);\n },\n );\n\n // Computed Props\n const userHasTelephonyPermission = computed(() => canAny([\"dial\", \"sms\"]));\n const capitalizedCrmName = crmDetails.displayName;\n const needConfigureSoftphoneNumber = computed(\n () => !user.value.softphoneNumber && userHasTelephonyPermission.value,\n );\n const needsToConfigurePhoneNumber = computed(\n () => user.value.needsToConfigurePhoneNumber,\n );\n const hasBrowserExtensionInstaller = computed(\n () =>\n canAny([\"record.meeting\", \"dial\"]) &&\n user.value.hasSidekickEnabled &&\n inChrome &&\n chromeExtensionId &&\n chromeWebstoreUrl.value,\n );\n const hasCrmPermission = computed(\n () => user.value.crmRequired && crmRequired,\n );\n const crmConnectUrl = computed(\n () => `/auth/redirect/${DOMPurify.sanitize(crmDetails.name)}`,\n );\n\n /** BEGIN: IntegrationApp related functionality */\n\n const crmToken = ref(null);\n\n const crmConnectIntegrationApp = async function () {\n const integrationApp = new IntegrationAppClient({\n token: crmToken.value,\n });\n\n const connection = await integrationApp\n .integration(crmDetails.name)\n .openNewConnection({\n showPoweredBy: false,\n allowMultipleConnections: false,\n });\n\n if (!connection || connection.connected === false) {\n showSnackbarError(\n \"A connection with your CRM could not be established\",\n );\n return;\n }\n\n try {\n const saveRequest = await axios.post(\"/api/v1/integration-app-connect\");\n\n if (saveRequest.data && saveRequest.data.success === true) {\n /** If all is good refresh the page here */\n return location.reload();\n }\n\n throw new Error(saveRequest.data.message);\n } catch (error) {\n console.log(error);\n showSnackbarError(normalizeError(error));\n }\n };\n\n const prepareIntegrationAppConnection = async function () {\n if (crmDetails?.viaIntegrationApp) {\n try {\n const response = await axios.get(\"/api/v1/integration-app-token\");\n crmToken.value = response.data.token;\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 if (crmRequired) {\n // Prepare the crm token only if the CRM is required\n prepareIntegrationAppConnection();\n }\n /** END: IntegrationApp related functionality */\n\n const showJobSelector = computed(() => !user.value.job);\n const showDeskPhoneNumber = computed(\n () =>\n needsToConfigurePhoneNumber.value &&\n !user.value.secondaryPhone &&\n team.value.softphoneRoutingPreference,\n );\n // Only users who can record calls\n const canRecord = computed(() => canAny([\"record.meeting\", \"dial\"]));\n\n const submitDisabled = computed(() => {\n if (unref(form).busy) return true;\n return sync.anyNeedAuthorization;\n });\n\n // Methods\n function showErrors() {\n if (backendErrors.length > 0) {\n backendErrors.forEach((error) =>\n showSnackbarError(error, undefined, undefined, false),\n );\n }\n }\n\n async function getSoftphoneNumber() {\n form.value.errors.forget();\n\n if (!form.value[\"softphone_country_code\"]) {\n return form.value.errors.set({\n softphone_country_code: [\"The country field is required.\"],\n });\n }\n\n const { valid } = await validate();\n\n if (!valid) {\n return;\n }\n\n try {\n const params = {\n pattern: form.value[\"softphone_pattern\"],\n country_code: form.value[\"softphone_country_code\"].toUpperCase(),\n capabilities: [\"voice\", \"sms\"],\n };\n\n /**\n * Exemplary response:\n * {\n * \"number\": \"+447782581047\",\n * \"national\": \"07782 581322\", // national is formatted number\n * \"pattern\": \"7782\" // patrten is area code\n * }\n */\n const { data } = await axios.get(\"/api/v1/phone-numbers\", {\n params,\n });\n\n form.value[\"softphone_number\"] = data?.number || \"\";\n form.value[\"softphone_national\"] = data?.national || \"\";\n form.value[\"softphone_pattern\"] = data?.pattern || null;\n } catch (errors) {\n form.value[\"softphone_number\"] = \"\";\n form.value[\"softphone_national\"] = \"\";\n form.value[\"softphone_pattern\"] = null;\n\n handleErrors(errors.response.data);\n }\n }\n\n function handleErrors(errors) {\n if (Object.prototype.hasOwnProperty.call(errors, \"errors\")) {\n form.value.errors.set(errors.errors);\n } else {\n form.value.errors.set(errors);\n }\n }\n\n async function getJobTitles() {\n const response = await axios.get(\n `/api/v1/organizations/${team.value.id}/job-titles`,\n );\n\n jobs.value = response.data;\n }\n\n function preselectSoftphoneCountryCode() {\n form.value[\"softphone_country_code\"] ||= selectedCountry()?.id || \"\";\n }\n\n function selectedCountry() {\n const countryCode = user.value.countryCode?.toLowerCase() || \"\";\n\n if (!countryCode) return \"\";\n\n return countries.value.find((country) => country.id === countryCode);\n }\n\n function onSoftphoneCountryCodeChange(countryCode) {\n form.value[\"softphone_country_code\"] = countryCode;\n form.value[\"softphone_pattern\"] = null;\n form.value[\"softphone_national\"] = \"\";\n form.value[\"softphone_number\"] = \"\";\n getSoftphoneNumber();\n }\n\n async function update() {\n try {\n form.value.busy = true;\n const payload = form.value;\n payload[\"country_code\"] = form.value[\"country_code\"].toUpperCase();\n Object.assign(payload, unref(userLocales.formData));\n\n await axios.post(\"/onboard\", payload);\n\n form.value.errors.forget();\n localStorage.setItem(\"showVerifyCallerIdModal\", true);\n updateUser();\n clearCachedForm();\n finishOnboardingUtils.onOnboardReady();\n } catch (errors) {\n const response = errors.response.data;\n const errorData = Object.hasOwn(response, \"errors\")\n ? errors.response.data.errors\n : errors.response.data;\n\n form.value.errors.set(errorData);\n if (errorData.sync_email) showSnackbarError(errorData.sync_email[0]);\n if (errorData.sync_calendar)\n showSnackbarError(errorData.sync_calendar[0]);\n } finally {\n form.value.busy = false;\n }\n }\n\n function resetLanguageError(value) {\n if (value) {\n form.value.errors.set(\"language\", \"\");\n }\n }\n\n // preselect timezone\n unwatch = watch(\n () => timezonesReady.value,\n (ready) => {\n // user already selected a timezone\n if (form.value.timezone) return;\n if (!ready) return;\n\n const browserTimezone =\n Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n const hasBrowserTimezone = timezonesArray.value.some(\n ({ tzCode }) => tzCode === browserTimezone,\n );\n\n if (hasBrowserTimezone) {\n form.value[\"timezone\"] = browserTimezone;\n } else {\n // Fallback to the initial user timezone (which should match the team timezone)\n form.value[\"timezone\"] = user.value?.timezone || \"\";\n\n const errorMessage = `Browser timezone doesn't match any of our timezones. Will fallback to the initial user timezone (which should match the team's timezone). Browser timezone: \"${browserTimezone}\"`;\n\n console.error(errorMessage);\n Sentry.captureException(new Error(errorMessage));\n }\n\n // Stop watching\n if (unwatch) {\n unwatch();\n }\n },\n { immediate: true },\n );\n\n onMounted(() => {\n showErrors();\n\n if (needConfigureSoftphoneNumber.value) {\n preselectSoftphoneCountryCode();\n if (!form.value.softphone_number) getSoftphoneNumber();\n }\n\n form.value[\"phone\"] ||= user.value.phone || \"\";\n form.value[\"secondary_phone\"] ||= user.value.secondaryPhone || \"\";\n form.value[\"softphone_number\"] ||= user.value.softphoneNumber || \"\";\n form.value[\"job_title_id\"] ||= user.value.job?.id || false;\n\n if (canRecord.value) {\n form.value[\"language\"] ||= \"\";\n }\n\n if (showJobSelector.value) getJobTitles();\n\n const webStoreUrlLink = document.querySelector(\n \"link[rel=chrome-webstore-item]\",\n );\n\n if (webStoreUrlLink) {\n chromeWebstoreUrl.value = DOMPurify.sanitize(\n webStoreUrlLink.getAttribute(\"href\"),\n );\n }\n });\n\n watch(\n form,\n (form) => {\n cachedForm.value = form;\n },\n { deep: true },\n );\n\n watch(\n () => userLocales.formData,\n (userLocales) => {\n cachedForm.value = {\n ...cachedForm.value,\n ...userLocales,\n };\n },\n );\n\n return {\n submitDisabled,\n ...finishOnboardingUtils,\n userLocales,\n sync,\n clearCachedForm,\n validate,\n faCircleInfo,\n faLongArrowRight,\n faSalesforce,\n faCheckCircle,\n faSync,\n timezonesArray,\n timezonesReady,\n crmConnection,\n crmName,\n crmDetails,\n softphoneCountryCode,\n countries,\n softphonePattern,\n chromeWebstoreUrl,\n chromeExtensionId,\n user,\n capitalizedCrmName,\n needConfigureSoftphoneNumber,\n needsToConfigurePhoneNumber,\n hasBrowserExtensionInstaller,\n hasCrmPermission,\n crmConnectUrl,\n crmConnectIntegrationApp,\n showJobSelector,\n showDeskPhoneNumber,\n canRecord,\n getSoftphoneNumber,\n update,\n resetLanguageError,\n form,\n jobs,\n crmToken,\n };\n },\n};\n\nfunction useFinishOnboarding() {\n const isSuggestingMobileApp = ref(null);\n const pageTitle = computed(() =>\n isMobileAppSupported && unref(isSuggestingMobileApp)\n ? \"Download App\"\n : \"Update your information\",\n );\n\n const store = useStore();\n\n const isWhiteLabel = computed(() => store.getters[\"platform/isWhiteLabel\"]);\n\n async function suggestMobileApp() {\n isSuggestingMobileApp.value = isMobileAppSupported;\n\n // for supported phones a corresponding download component is displayed\n if (unref(isSuggestingMobileApp)) return;\n\n // for non mobile devices a modal is displayed and an action is required\n if (!isMobile) await openMobileAppModal();\n\n finishOnboarding();\n }\n\n async function finishOnboarding() {\n window.location = \"/dashboard\";\n }\n\n async function openMobileAppModal() {\n const modal = await openModal(MobileAppModal);\n\n return new Promise((resolve) => (modal.onclose = resolve));\n }\n\n function onOnboardReady() {\n if (unref(isWhiteLabel)) return finishOnboarding();\n\n suggestMobileApp();\n }\n\n return {\n pageTitle,\n isSuggestingMobileApp,\n finishOnboarding,\n onOnboardReady,\n };\n}\n\nfunction useUserCache(key, defaultValue = {}) {\n const store = useStore();\n const id = computed(() => store.getters[\"platform/user\"]?.id);\n const cache = useLocalStorage(key, {});\n\n function clearCache() {\n cache.value = undefined;\n }\n\n return {\n cache: computed({\n get() {\n return cache.value[id.value] || defaultValue;\n },\n set(value) {\n cache.value = {\n [id.value]: value,\n };\n },\n }),\n clearCache,\n };\n}\n</script>\n\n<style module lang=\"less\" src=\"./Onboard.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}]...
|
-6043481845154234215
|
-8753422125917499098
|
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
9
Previous Highlighted Error
Next Highlighted Error
<template>
<WelcomeLayout v-bind="{ title: pageTitle }">
<MobileAppDownload
v-if="isSuggestingMobileApp"
@ready="finishOnboarding()"
/>
<div v-else :class="$style.container">
<BuildInfo />
<form>
<!-- GENERAL Section-->
<div :class="$style.sectionTitle">General</div>
<!-- Job title -->
<SelectField
v-if="showJobSelector && jobs.length > 0"
required="required"
name="jobTitleId"
fieldLabel="Select position"
v-model="form.job_title_id"
:options="jobs"
track-by="id"
:hasError="form.errors.has('job_title_id')"
:errorMessage="form.errors.get('job_title_id')"
optionLabel="name"
data-testid="job-title-selector"
/>
<!-- Timezone -->
<SelectField
required="required"
name="timezone"
optionLabel="label"
fieldLabel="Timezone"
:disabled="!timezonesReady"
v-model="form.timezone"
:options="timezonesArray"
:hasError="form.errors.has('timezone')"
:errorMessage="form.errors.get('timezone')"
track-by="tzCode"
/>
<!-- Transcription language -->
<template v-if="canRecord">
<!-- LANGUAGE Section-->
<div :class="$style.sectionTitle">
Languages spoken during calls
<tippy theme="primary" placement="bottom">
<FontAwesomeIcon :icon="faCircleInfo" :class="$style.infoIcon" />
<template #content>
<div :class="$style.textLeft">
Select all languages spoken during call.<br />
We automatically detect languages and if one isn’t identified,
it will be translated into the default language
</div>
</template>
</tippy>
</div>
<UserLocalesInputs
:hasError="
form.errors.has('language') || form.errors.has('locales')
"
:errorMessage="
form.errors.get('language') || form.errors.get('locales')
"
:buttonAttrs="{ mods: 'primary seamless' }"
v-bind="{ userLocales }"
/>
</template>
<!-- PHONE Section-->
<div
v-if="
needsToConfigurePhoneNumber ||
showDeskPhoneNumber ||
needConfigureSoftphoneNumber
"
:class="$style.sectionTitle"
>
Phone
</div>
<!-- Configure Phone number -->
<div :class="$style.panel" v-if="needsToConfigurePhoneNumber">
<PhoneField
required="required"
name="phone"
fieldLabel="Phone number"
v-model="form.phone"
tooltip="We'll use this to send you notifications and identify you."
:hasError="form.errors.has('phone')"
:errorMessage="form.errors.get('phone')"
data-testid="phone-input"
/>
</div>
<!-- Configure Desk Phone number -->
<template v-if="showDeskPhoneNumber">
<PhoneField
name="secondary_phone"
fieldLabel="Desk number"
v-model="form.secondary_phone"
:hasError="form.errors.has('secondary_phone')"
:errorMessage="form.errors.get('secondary_phone')"
data-testid="desk-phone-input"
/>
</template>
<!-- Softphone Number (SMS & Inbound number) -->
<div
:class="[$style.panel, $style.conferenceNumberPanel]"
v-if="needConfigureSoftphoneNumber"
>
<!-- Country Code selector -->
<PhoneField
:class="$style.countryCodeInput"
required="required"
name="softphone_country_code"
fieldLabel="Country"
:initialCountry="softphoneCountryCode"
:separateDialCode="true"
v-model="softphoneCountryCode"
@onCountryChange="softphoneCountryCode = $event.iso2"
/>
<!-- Area Code selector -->
<Field
v-slot="{ field, errorMessage }"
name="softphone_pattern"
:rules="`numeric|min:0|max:${softphonePattern.maxLength}`"
v-model="form.softphone_pattern"
>
<InputField
v-bind="field"
:disabled="form.busy"
fieldLabel="Area Code"
:hasError="!!errorMessage"
data-testid="area-code-input"
/>
</Field>
<!-- Displays the softphone number in a user firendly format, example: "[PHONE]" -->
<InputField
:disabled="form.busy"
readonly
name="softphone_national"
fieldLabel="SMS & Inbound"
v-model="form.softphone_national"
:hasError="form.errors.has('softphone_national')"
data-testid="softphone-input"
/>
<!-- Generate new softphone number -->
<button
type="button"
name="button"
data-testid="button-generate-softphone-number"
@click="getSoftphoneNumber"
:disabled="form.busy"
>
<font-awesome-icon :icon="faSync" />
</button>
<!-- Error messages -->
<p class="error" v-show="form.errors.has('softphone_number')">
{{ form.errors.get("softphone_number") }}
</p>
<ErrorMessage name="softphone_pattern" v-slot="{ message }">
<p class="error" v-show="!!message">
{{ message }}
</p>
</ErrorMessage>
<p class="error" v-show="form.errors.has('softphone_pattern')">
{{ form.errors.get("softphone_pattern") }}
</p>
<p class="error" v-show="form.errors.has('softphone_country_code')">
{{ form.errors.get("softphone_country_code") }}
</p>
</div>
<!-- CONNECT -->
<div
v-if="
hasBrowserExtensionInstaller || hasCrmPermission || sync.ui.visible
"
:class="$style.sectionTitle"
>
Connect/Sync settings
</div>
<!-- CRM connection status -->
<div data-testid="integrations-connections" :class="$style.actionsRows">
<template v-if="hasCrmPermission">
<span>Connect {{ capitalizedCrmName }}</span>
<!-- CRM connected -->
<template v-if="crmConnection">
<AppButton
data-testid="crm-connected"
variant="secondary"
readonly
>
<BrandLogo :name="crmDetails.name" />
Connected
</AppButton>
</template>
<!-- CRM not connected -->
<template v-else>
<AppButton
v-if="crmDetails.viaIntegrationApp"
@click="crmConnectIntegrationApp"
variant="secondary"
:busy="!crmToken"
data-testid="crm-signin"
>
<B...
|
45775
|
|
45869
|
NULL
|
0
|
2026-04-17T10:10:47.138934+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776420647138_m1.jpg...
|
Slack
|
jiminny-x-integration-app (Channel) - Jiminny Inc jiminny-x-integration-app (Channel) - Jiminny Inc - 2 new items - 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
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
jiminny_social
Nikolay Nikolov
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Galya Dimitrova
Stoyan Tanev
Vasil Vasilev
Nikolay Ivanov
Aneliya Angelova
Ves
Steliyan Georgiev
Jira Cloud
Toast
Google Calendar
Messages
Messages
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Membrane
APP
Nov 11th, 2025 at 12:38:55 AM
12:38 AM
Heads up – we’re moving to a new domain and legal name!
Heads up – we’re moving to a new domain and legal name!
In the coming days and weeks, we’ll be transitioning from
integration.app
integration.app
domain to
getmembrane.com
getmembrane.com
. You’ll start seeing our website, docs, and console automatically redirect to the new domain.
No action is needed on your side — all existing APIs and SDKs will continue working as usual.
Additionally, we have changed our legal name to Membrane Inc. It will be used in all the paperwork going forward.
We’ll share the official launch announcement in the next couple of weeks.
1 reaction, react with +1 emoji
1
Add reaction…
Jump to date
Membrane
APP
Dec 15th, 2025 at 7:29:37 PM
7:29 PM
Exclusive access
We’ve been working on a new capability at
Membrane
called
self-integration.
Instead of relying on pre-built integrations, your AI agent can now build integrations itself, on the fly, to any app.
We’re partnering with a small group of teams to pilot this, including our customers. If it sounds relevant to what you’re building, I’d love to include you.
For now, this is a closed experience as we want to refine the end-to-end flow with close partners as part of our
Founding Cohort for Self-Integrations
.
Read more here:
https://self-integration.getmembrane.com
https://self-integration.getmembrane.com
. Check out
self-integration manifesto
once in, if you’re interested in the vision and why we are working on this.
Reply or react a
if you’re interested
— we’re happy to walk you through it over a very short call.
Jump to date
Lukas Kovalik
Yesterday at 11:04:11 AM
11:04 AM
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 previous conversation
https://jiminny.slack.com/archives/C07RAC4U86M/p1748957897141919
https://jiminny.slack.com/archives/C07RAC4U86M/p1748957897141919
. Could you please have a look if there is any change?
Remove preview
Lukas Kovalik
Lukas Kovalik
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 (
https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection
https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection
) upon successful login. This functionality was working just a few weeks ago.
(edited)
Thread in jiminny-x-integration-app
Thread in
jiminny-x-integration-app
|
Jun 3rd, 2025
Jun 3rd, 2025
|
View message
View message
10 replies
Last reply 23 hours ago
View thread
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply to thread
Forward message…
Save for later
Summarize thread
More actions
10 external people
are from
Membrane
Lukas Kovalik
Yesterday at 1:47:49 PM
Yesterday at 1:47 PM
Here is the response GET
https://api.getmembrane.com/integrations/zohocrm
https://api.getmembrane.com/integrations/zohocrm
{
"id": "66fe6c913202f3a165e3c14d",
"name": "Zoho CRM",
"uuid": "e02598b1-2f23-4f88-8fa8-8d9f9d420f89",
"key": "zohocrm",
"state": "READY",
"errors": [],
"revision": "8d27bda5-8eca-46d9-90bd-70f98efd970d",
"createdAt": "2024-10-03T10:06:09.911Z",
"updatedAt": "2026-04-16T10:24:19.276Z",
"isDeactivated": false,
"logoUri": "
https://static.integration.app/connectors/zoho-crm/logo.png
https://static.integration.app/connectors/zoho-crm/logo.png
",
"connectorId": "64a158e7d2605720d232e07b",
"connectorVersion": "3.0.3",
"oAuthCallbackUri": "
https://api.integration.app/oauth-callback
https://api.integration.app/oauth-callback
",
"hasMissingParameters": false,
"hasDocumentation": false,
"hasOperations": true,
"operationsCount": 569,
"hasData": true,
"dataCollectionsCount": 20,
"hasEvents": false,
"eventsCount": 0,
"hasGlobalWebhooks": false,
"hasUdm": true,
"authType": "client-credentials",
"connection": {
"id": "69e0b983da98fa74f98aebfb",
"name": "Connection to 66fe6c913202f3a165e3c14d for Dev Zoho CRM client",
"userId": "1ece66c8-feb1-4df1-b321-21607daf4623",
"tenantId": "69e0b3faef3e7b6248189289",
"isTest": false,
"connected": true,
"state": "READY",
"errors": [],
"integrationId": "66fe6c913202f3a165e3c14d",
"externalAppId": "6671653e7e2d642e4e41b0fa",
"authOptionKey": "oauth",
"createdAt": "2026-04-16T10:27:15.579Z",
"updatedAt": "2026-04-16T10:34:08.702Z",
"retryAttempts": 0,
"isDeactivated": false
},
"authOptions": [
{
"key": "auth-proxy",
"type": "proxy",
"title": "Connect via Membrane",
"inputSchema": {
"type": "object",
"properties": {
"account_type": {
"type": "string",
"enum": [
"production",
"developer",
"sandbox"
],
"description": "What is the type of your Zoho account you want to connect to?",
"default": "production"
}
}
},
"ui": {
"schema": {
"type": "object",
"properties": {
"account_type": {
"type": "string",
"enum": [
"production",
"developer",
"sandbox"
],
"description": "What is the type of your Zoho account you want to connect to?",
"default": "production"
}
}
}
}
},
{
"key": "oauth",
"type": "oauth2",
"title": "OAuth 2.0",
"inputSchema": {
"type": "object",
"properties": {
"account_type": {
"type": "string",
"enum": [
"production",
"developer",
"sandbox"
],
"description": "What is the type of your Zoho account you want to connect to?",
"default": "production"
}
}
},
"ui": {
"schema": {
"type": "object",
"properties": {
"account_type": {
"type": "string",
"enum": [
"production",
"developer",
"sandbox"
],
"description": "What is the type of your Zoho account you want to connect to?",
"default": "production"
}
}
}
}
}
]
}
Membrane AI Assistant
APP
Yesterday at 1:49:27 PM
Yesterday at 1:49 PM
Ah, I see now! You're referring to the connection object field in the API response, not webhook events. You're right - there's been a change from
disconnected
to
connected
in the API response.
Looking at your API response:
Old field:
"disconnected": false
New field:
"connected": true
This is a breaking change that affects the logic you built around checking connection status. The boolean logic is also inverted (
disconnected: false
= connection is active, while
connected: true
= connection is active).
Let me escalate this to the team to confirm when this change was introduced and whether both fields are temporarily supported during a transition period, or if you need to update your code immediately to use the
connected
field instead of
disconnected
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Forward message…
Save for later
More actions
Yesterday at 1:50:02 PM
1:50
I've escalated this to our engineering team to get clarification on the API change. They'll get back to you shortly with details about when this change was introduced and guidance on how to handle it in your code.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Forward message…
Save for later
More actions
Membrane AI Assistant
APP
Yesterday at 2:26:57 PM
Yesterday at 2:26 PM
Good news! I've investigated the API change and here's what I found:
Both fields are still returned - no breaking change!
The API response now includes both:
connected: true
(new canonical field)
disconnected: false
(deprecated but still returned for backward compatibility)
What happened:
On April 3, 2026, we updated the platform to use
connected
as the primary field instead of
disconnected
for better code readability. However, to ensure backward compatibility, the API automatically derives and includes the
disconnected
field in all responses (
disconnected = !connected
).
What this means for you:
Your existing code using...
|
[{"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":true,"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":"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,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"frontend","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"infra-changes","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"jiminny_social","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Nikolov","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"role_description":"text"},{"role":"AXStaticText","text":",","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Steliyan Georgiev","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Ivanov","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Ves","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Steliyan Georgiev","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Google Calendar","depth":23,"role_description":"text"},{"role":"AXRadioButton","text":"Messages","depth":18,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Messages","depth":20,"role_description":"text"},{"role":"AXRadioButton","text":"More","depth":19,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Add and Edit Channel Tabs","depth":18,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Canvas","depth":18,"role_description":"text"},{"role":"AXStaticText","text":"List","depth":18,"role_description":"text"},{"role":"AXStaticText","text":"Folder","depth":18,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":24,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Membrane","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"","depth":25,"role_description":"text"},{"role":"AXLink","text":"Nov 11th, 2025 at 12:38:55 AM","depth":25,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12:38 AM","depth":26,"role_description":"text"},{"role":"AXHeading","text":"Heads up – we’re moving to a new domain and legal name!","depth":25,"role_description":"heading"},{"role":"AXStaticText","text":"Heads up – we’re moving to a new domain and legal name!","depth":27,"role_description":"text"},{"role":"AXStaticText","text":"In the coming days and weeks, we’ll be transitioning from","depth":25,"role_description":"text"},{"role":"AXLink","text":"integration.app","depth":25,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"integration.app","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"domain to","depth":25,"role_description":"text"},{"role":"AXLink","text":"getmembrane.com","depth":25,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"getmembrane.com","depth":26,"role_description":"text"},{"role":"AXStaticText","text":". You’ll start seeing our website, docs, and console automatically redirect to the new domain.","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"No action is needed on your side — all existing APIs and SDKs will continue working as usual.","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"Additionally, we have changed our legal name to Membrane Inc. It will be used in all the paperwork going forward.","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"We’ll share the official launch announcement in the next couple of weeks.","depth":25,"role_description":"text"},{"role":"AXCheckBox","text":"1 reaction, react with +1 emoji","depth":26,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":27,"role_description":"text"},{"role":"AXButton","text":"Add reaction…","depth":26,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Jump to date","depth":24,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Membrane","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"","depth":25,"role_description":"text"},{"role":"AXLink","text":"Dec 15th, 2025 at 7:29:37 PM","depth":25,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7:29 PM","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"Exclusive access","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"We’ve been working on a new capability at","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"Membrane","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"called","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"self-integration.","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"Instead of relying on pre-built integrations, your AI agent can now build integrations itself, on the fly, to any app.","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"We’re partnering with a small group of teams to pilot this, including our customers. If it sounds relevant to what you’re building, I’d love to include you.","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"For now, this is a closed experience as we want to refine the end-to-end flow with close partners as part of our","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"Founding Cohort for Self-Integrations","depth":25,"role_description":"text"},{"role":"AXStaticText","text":".","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"Read more here:","depth":25,"role_description":"text"},{"role":"AXLink","text":"https://self-integration.getmembrane.com","depth":25,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://self-integration.getmembrane.com","depth":26,"role_description":"text"},{"role":"AXStaticText","text":". Check out","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"self-integration manifesto","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"once in, if you’re interested in the vision and why we are working on this.","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"Reply or react a","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"if you’re interested","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"— we’re happy to walk you through it over a very short call.","depth":25,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":24,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":25,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 11:04:11 AM","depth":25,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"11:04 AM","depth":26,"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 previous conversation","depth":26,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.slack.com/archives/C07RAC4U86M/p1748957897141919","depth":26,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.slack.com/archives/C07RAC4U86M/p1748957897141919","depth":27,"role_description":"text"},{"role":"AXStaticText","text":". Could you please have a look if there is any change?","depth":26,"role_description":"text"},{"role":"AXButton","text":"Remove preview","depth":27,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":27,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Lukas Kovalik","depth":29,"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 Promise (","depth":28,"role_description":"text"},{"role":"AXLink","text":"https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection","depth":28,"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":29,"role_description":"text"},{"role":"AXStaticText","text":") upon successful login. This functionality was working just a few weeks ago.","depth":28,"role_description":"text"},{"role":"AXStaticText","text":"(edited)","depth":27,"role_description":"text"},{"role":"AXLink","text":"Thread in jiminny-x-integration-app","depth":27,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Thread in","depth":28,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":28,"role_description":"text"},{"role":"AXStaticText","text":"|","depth":27,"role_description":"text"},{"role":"AXLink","text":"Jun 3rd, 2025","depth":27,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jun 3rd, 2025","depth":28,"role_description":"text"},{"role":"AXStaticText","text":"|","depth":27,"role_description":"text"},{"role":"AXLink","text":"View message","depth":27,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View message","depth":28,"role_description":"text"},{"role":"AXButton","text":"10 replies","depth":25,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply 23 hours ago","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":26,"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":27,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":27,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":27,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":27,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply to thread","depth":27,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":27,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":27,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Summarize thread","depth":27,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":27,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"10 external people","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"are from","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Membrane","depth":23,"role_description":"text"},{"role":"AXTextArea","text":"","depth":24,"value":"","role_description":"text entry area","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":"Yesterday at 1:47:49 PM","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Yesterday at 1:47 PM","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"Here is the response GET","depth":24,"role_description":"text"},{"role":"AXLink","text":"https://api.getmembrane.com/integrations/zohocrm","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://api.getmembrane.com/integrations/zohocrm","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"{\n\t\"id\": \"66fe6c913202f3a165e3c14d\",\n\t\"name\": \"Zoho CRM\",\n\t\"uuid\": \"e02598b1-2f23-4f88-8fa8-8d9f9d420f89\",\n\t\"key\": \"zohocrm\",\n\t\"state\": \"READY\",\n\t\"errors\": [],\n\t\"revision\": \"8d27bda5-8eca-46d9-90bd-70f98efd970d\",\n\t\"createdAt\": \"2024-10-03T10:06:09.911Z\",\n\t\"updatedAt\": \"2026-04-16T10:24:19.276Z\",\n\t\"isDeactivated\": false,\n\t\"logoUri\": \"","depth":24,"role_description":"text"},{"role":"AXLink","text":"https://static.integration.app/connectors/zoho-crm/logo.png","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://static.integration.app/connectors/zoho-crm/logo.png","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"\",\n\t\"connectorId\": \"64a158e7d2605720d232e07b\",\n\t\"connectorVersion\": \"3.0.3\",\n\t\"oAuthCallbackUri\": \"","depth":24,"role_description":"text"},{"role":"AXLink","text":"https://api.integration.app/oauth-callback","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://api.integration.app/oauth-callback","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"\",\n\t\"hasMissingParameters\": false,\n\t\"hasDocumentation\": false,\n\t\"hasOperations\": true,\n\t\"operationsCount\": 569,\n\t\"hasData\": true,\n\t\"dataCollectionsCount\": 20,\n\t\"hasEvents\": false,\n\t\"eventsCount\": 0,\n\t\"hasGlobalWebhooks\": false,\n\t\"hasUdm\": true,\n\t\"authType\": \"client-credentials\",\n\t\"connection\": {\n\t\t\"id\": \"69e0b983da98fa74f98aebfb\",\n\t\t\"name\": \"Connection to 66fe6c913202f3a165e3c14d for Dev Zoho CRM client\",\n\t\t\"userId\": \"1ece66c8-feb1-4df1-b321-21607daf4623\",\n\t\t\"tenantId\": \"69e0b3faef3e7b6248189289\",\n\t\t\"isTest\": false,\n\t\t\"connected\": true,\n\t\t\"state\": \"READY\",\n\t\t\"errors\": [],\n\t\t\"integrationId\": \"66fe6c913202f3a165e3c14d\",\n\t\t\"externalAppId\": \"6671653e7e2d642e4e41b0fa\",\n\t\t\"authOptionKey\": \"oauth\",\n\t\t\"createdAt\": \"2026-04-16T10:27:15.579Z\",\n\t\t\"updatedAt\": \"2026-04-16T10:34:08.702Z\",\n\t\t\"retryAttempts\": 0,\n\t\t\"isDeactivated\": false\n\t},\n\t\"authOptions\": [\n\t\t{\n\t\t\t\"key\": \"auth-proxy\",\n\t\t\t\"type\": \"proxy\",\n\t\t\t\"title\": \"Connect via Membrane\",\n\t\t\t\"inputSchema\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"account_type\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"enum\": [\n\t\t\t\t\t\t\t\"production\",\n\t\t\t\t\t\t\t\"developer\",\n\t\t\t\t\t\t\t\"sandbox\"\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"description\": \"What is the type of your Zoho account you want to connect to?\",\n\t\t\t\t\t\t\"default\": \"production\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"ui\": {\n\t\t\t\t\"schema\": {\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\"account_type\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"enum\": [\n\t\t\t\t\t\t\t\t\"production\",\n\t\t\t\t\t\t\t\t\"developer\",\n\t\t\t\t\t\t\t\t\"sandbox\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"description\": \"What is the type of your Zoho account you want to connect to?\",\n\t\t\t\t\t\t\t\"default\": \"production\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth\",\n\t\t\t\"type\": \"oauth2\",\n\t\t\t\"title\": \"OAuth 2.0\",\n\t\t\t\"inputSchema\": {\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"account_type\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"enum\": [\n\t\t\t\t\t\t\t\"production\",\n\t\t\t\t\t\t\t\"developer\",\n\t\t\t\t\t\t\t\"sandbox\"\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"description\": \"What is the type of your Zoho account you want to connect to?\",\n\t\t\t\t\t\t\"default\": \"production\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"ui\": {\n\t\t\t\t\"schema\": {\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\"account_type\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"enum\": [\n\t\t\t\t\t\t\t\t\"production\",\n\t\t\t\t\t\t\t\t\"developer\",\n\t\t\t\t\t\t\t\t\"sandbox\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"description\": \"What is the type of your Zoho account you want to connect to?\",\n\t\t\t\t\t\t\t\"default\": \"production\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n}","depth":24,"role_description":"text"},{"role":"AXButton","text":"Membrane AI Assistant","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 1:49:27 PM","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Yesterday at 1:49 PM","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"Ah, I see now! You're referring to the connection object field in the API response, not webhook events. You're right - there's been a change from","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"disconnected","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"to","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"connected","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"in the API response.","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Looking at your API response:","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"Old field:","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"\"disconnected\": false","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"New field:","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"\"connected\": true","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"This is a breaking change that affects the logic you built around checking connection status. The boolean logic is also inverted (","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"disconnected: false","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"= connection is active, while","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"connected: true","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"= connection is active).","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Let me escalate this to the team to confirm when this change was introduced and whether both fields are temporarily supported during a transition period, or if you need to update your code immediately to use the","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"connected","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"field instead of","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"disconnected","depth":24,"role_description":"text"},{"role":"AXStaticText","text":".","depth":23,"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":"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":"Yesterday at 1:50:02 PM","depth":24,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1:50","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"I've escalated this to our engineering team to get clarification on the API change. They'll get back to you shortly with details about when this change was introduced and guidance on how to handle it in your code.","depth":24,"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":"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":"Membrane AI Assistant","depth":23,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"APP","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"","depth":23,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 2:26:57 PM","depth":23,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Yesterday at 2:26 PM","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"Good news! I've investigated the API change and here's what I found:","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"Both fields are still returned - no breaking change!","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"The API response now includes both:","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"connected: true","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"(new canonical field)","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"disconnected: false","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"(deprecated but still returned for backward compatibility)","depth":25,"role_description":"text"},{"role":"AXStaticText","text":"What happened:","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"On April 3, 2026, we updated the platform to use","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"connected","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"as the primary field instead of","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"disconnected","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"for better code readability. However, to ensure backward compatibility, the API automatically derives and includes the","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"disconnected","depth":24,"role_description":"text"},{"role":"AXStaticText","text":"field in all responses (","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"disconnected = !connected","depth":24,"role_description":"text"},{"role":"AXStaticText","text":").","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"What this means for you:","depth":23,"role_description":"text"},{"role":"AXStaticText","text":"","depth":26,"role_description":"text"},{"role":"AXStaticText","text":"Your existing code using","depth":25,"role_description":"text"}]...
|
9067804690149296010
|
-3524008064445351862
|
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
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
jiminny_social
Nikolay Nikolov
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Galya Dimitrova
Stoyan Tanev
Vasil Vasilev
Nikolay Ivanov
Aneliya Angelova
Ves
Steliyan Georgiev
Jira Cloud
Toast
Google Calendar
Messages
Messages
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Membrane
APP
Nov 11th, 2025 at 12:38:55 AM
12:38 AM
Heads up – we’re moving to a new domain and legal name!
Heads up – we’re moving to a new domain and legal name!
In the coming days and weeks, we’ll be transitioning from
integration.app
integration.app
domain to
getmembrane.com
getmembrane.com
. You’ll start seeing our website, docs, and console automatically redirect to the new domain.
No action is needed on your side — all existing APIs and SDKs will continue working as usual.
Additionally, we have changed our legal name to Membrane Inc. It will be used in all the paperwork going forward.
We’ll share the official launch announcement in the next couple of weeks.
1 reaction, react with +1 emoji
1
Add reaction…
Jump to date
Membrane
APP
Dec 15th, 2025 at 7:29:37 PM
7:29 PM
Exclusive access
We’ve been working on a new capability at
Membrane
called
self-integration.
Instead of relying on pre-built integrations, your AI agent can now build integrations itself, on the fly, to any app.
We’re partnering with a small group of teams to pilot this, including our customers. If it sounds relevant to what you’re building, I’d love to include you.
For now, this is a closed experience as we want to refine the end-to-end flow with close partners as part of our
Founding Cohort for Self-Integrations
.
Read more here:
https://self-integration.getmembrane.com
https://self-integration.getmembrane.com
. Check out
self-integration manifesto
once in, if you’re interested in the vision and why we are working on this.
Reply or react a
if you’re interested
— we’re happy to walk you through it over a very short call.
Jump to date
Lukas Kovalik
Yesterday at 11:04:11 AM
11:04 AM
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 previous conversation
https://jiminny.slack.com/archives/C07RAC4U86M/p1748957897141919
https://jiminny.slack.com/archives/C07RAC4U86M/p1748957897141919
. Could you please have a look if there is any change?
Remove preview
Lukas Kovalik
Lukas Kovalik
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 (
https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection
https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection
) upon successful login. This functionality was working just a few weeks ago.
(edited)
Thread in jiminny-x-integration-app
Thread in
jiminny-x-integration-app
|
Jun 3rd, 2025
Jun 3rd, 2025
|
View message
View message
10 replies
Last reply 23 hours ago
View thread
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply to thread
Forward message…
Save for later
Summarize thread
More actions
10 external people
are from
Membrane
Lukas Kovalik
Yesterday at 1:47:49 PM
Yesterday at 1:47 PM
Here is the response GET
https://api.getmembrane.com/integrations/zohocrm
https://api.getmembrane.com/integrations/zohocrm
{
"id": "66fe6c913202f3a165e3c14d",
"name": "Zoho CRM",
"uuid": "e02598b1-2f23-4f88-8fa8-8d9f9d420f89",
"key": "zohocrm",
"state": "READY",
"errors": [],
"revision": "8d27bda5-8eca-46d9-90bd-70f98efd970d",
"createdAt": "2024-10-03T10:06:09.911Z",
"updatedAt": "2026-04-16T10:24:19.276Z",
"isDeactivated": false,
"logoUri": "
https://static.integration.app/connectors/zoho-crm/logo.png
https://static.integration.app/connectors/zoho-crm/logo.png
",
"connectorId": "64a158e7d2605720d232e07b",
"connectorVersion": "3.0.3",
"oAuthCallbackUri": "
https://api.integration.app/oauth-callback
https://api.integration.app/oauth-callback
",
"hasMissingParameters": false,
"hasDocumentation": false,
"hasOperations": true,
"operationsCount": 569,
"hasData": true,
"dataCollectionsCount": 20,
"hasEvents": false,
"eventsCount": 0,
"hasGlobalWebhooks": false,
"hasUdm": true,
"authType": "client-credentials",
"connection": {
"id": "69e0b983da98fa74f98aebfb",
"name": "Connection to 66fe6c913202f3a165e3c14d for Dev Zoho CRM client",
"userId": "1ece66c8-feb1-4df1-b321-21607daf4623",
"tenantId": "69e0b3faef3e7b6248189289",
"isTest": false,
"connected": true,
"state": "READY",
"errors": [],
"integrationId": "66fe6c913202f3a165e3c14d",
"externalAppId": "6671653e7e2d642e4e41b0fa",
"authOptionKey": "oauth",
"createdAt": "2026-04-16T10:27:15.579Z",
"updatedAt": "2026-04-16T10:34:08.702Z",
"retryAttempts": 0,
"isDeactivated": false
},
"authOptions": [
{
"key": "auth-proxy",
"type": "proxy",
"title": "Connect via Membrane",
"inputSchema": {
"type": "object",
"properties": {
"account_type": {
"type": "string",
"enum": [
"production",
"developer",
"sandbox"
],
"description": "What is the type of your Zoho account you want to connect to?",
"default": "production"
}
}
},
"ui": {
"schema": {
"type": "object",
"properties": {
"account_type": {
"type": "string",
"enum": [
"production",
"developer",
"sandbox"
],
"description": "What is the type of your Zoho account you want to connect to?",
"default": "production"
}
}
}
}
},
{
"key": "oauth",
"type": "oauth2",
"title": "OAuth 2.0",
"inputSchema": {
"type": "object",
"properties": {
"account_type": {
"type": "string",
"enum": [
"production",
"developer",
"sandbox"
],
"description": "What is the type of your Zoho account you want to connect to?",
"default": "production"
}
}
},
"ui": {
"schema": {
"type": "object",
"properties": {
"account_type": {
"type": "string",
"enum": [
"production",
"developer",
"sandbox"
],
"description": "What is the type of your Zoho account you want to connect to?",
"default": "production"
}
}
}
}
}
]
}
Membrane AI Assistant
APP
Yesterday at 1:49:27 PM
Yesterday at 1:49 PM
Ah, I see now! You're referring to the connection object field in the API response, not webhook events. You're right - there's been a change from
disconnected
to
connected
in the API response.
Looking at your API response:
Old field:
"disconnected": false
New field:
"connected": true
This is a breaking change that affects the logic you built around checking connection status. The boolean logic is also inverted (
disconnected: false
= connection is active, while
connected: true
= connection is active).
Let me escalate this to the team to confirm when this change was introduced and whether both fields are temporarily supported during a transition period, or if you need to update your code immediately to use the
connected
field instead of
disconnected
.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Forward message…
Save for later
More actions
Yesterday at 1:50:02 PM
1:50
I've escalated this to our engineering team to get clarification on the API change. They'll get back to you shortly with details about when this change was introduced and guidance on how to handle it in your code.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Forward message…
Save for later
More actions
Membrane AI Assistant
APP
Yesterday at 2:26:57 PM
Yesterday at 2:26 PM
Good news! I've investigated the API change and here's what I found:
Both fields are still returned - no breaking change!
The API response now includes both:
connected: true
(new canonical field)
disconnected: false
(deprecated but still returned for backward compatibility)
What happened:
On April 3, 2026, we updated the platform to use
connected
as the primary field instead of
disconnected
for better code readability. However, to ensure backward compatibility, the API automatically derives and includes the
disconnected
field in all responses (
disconnected = !connected
).
What this means for you:
Your existing code using
FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp‹ ≥0 lhl • { Support Daily • in 1h 50m A 100% <4 8 Fri 17 Apr 13:10:46)Zoho Accounts — Workaccounts.zoho.eu/oauth/v2/auth?client_id=1000.20TYIF96EC5MJE90E7FUVE9Y7WTWSR&redirect_uri=https%3A%2F%2Fapi.getmembrar #=DOCKER•₴1DEV (-zsh)APP (-2create mode 100644 app/Component/AiAutomation/Servicecreate mode 100644app/Component/DealRisks/GroupDealRcreate mode 100644 app/Component/MediaPipeline/Handlecreate mode 100644 app/Component/MediaPipeline/Handlecreate mode 100644app/Component/ParagraphBreaker/DTO.create mode 100644app/Component/ParagraphBreaker/Serdelete mode 100644 app/Component/Transcription/Listendelete mode 100644app/Component/Transcription/Serviccreate mode 100644app/Component/Transcription/Serviccreate mode 100644 app/Component/Transcription/Transccreate mode 100644 app/Component/Transcription/Transcdelete mode 100644app/Component/Transcription/V0/Tracreate mode 100644 app/Console/Commands/Crm/Hubspot/Rcreate mode 100644 app/Console/Commands/Crm/SyncOppor-create mode 100644 app/Contracts/Crm/SyncableCrmObjeccreate mode 100644 app/Events/Crm/RemoteCrmRecordDelecreate mode 100644 app/Listeners/Crm/RemoteCrmRecordDcreate mode 100644 app/Services/Activity/HubSpot/Redicreate mode 100644 app/Services/Activity/HubSpot/Zoomcreate mode 100644 app/Services/Crm/CrmObjects/Validacreate mode 100644 contrib/tmp/hubspot-associations-Ucreate mode 100644 contrib/tmp/hubspot-associations-Ucreate mode 100644 contrib/tmp/hubspot-associations-U:create mode 100644 contrib/tmp/hubspot-associations-U:create mode 100644 database/migrations/2026_04_14_140delete mode 100644 resources/views/pdf/transcription.create mode 100644 tests/Unit/Component/AiAutomation/create mode 100644 tests/Unit/Component/MediaPipeline.create mode 100644 tests/Unit/Component/MediaPipelinecreate mode 100644 tests/Unit/Component/ParagraphBreadelete mode 100644 tests/Unit/Component/Transcriptioncreate mode 100644 tests/Unit/Component/Transcriptiondelete mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Component/Transcription,delete mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Guards/SsoTest.phpcreate mode 100644 tests/Unit/Jobs/Mailbox/SyncInboxTcreate mode 100644 tests/Unit/Listeners/Crm/RemoteCrmlcreate mode 100644 tests/Unit/Services/Activity/HubSpcreate mode 100644 tests/Unit/Services/Activity/Meeticreate mode 100644 tests/Unit/Services/Crm/CrmObjects,lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/appSwitched to a new branch 'JY-20692-fix-integration-appukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/appJiminny Integ...20H0MembraneMembrane would like to access the following information.@ CRMJiminny Inc• Perform CRUD operations on the modules• Full access to Read, Create, Update and Delete user data in your organizationGroup scope to perform CRUD operations on metadata• get org dataFull access to ZohoCRM notificationsTo get the pipeline along with associated stagesget profilesTo read, create, update and delete global picklist• To fetch data using CRM Object Query Language COQLl allow Membrane to access the above data from my Zoho account.AcceptReject© 2026, Zoho Corporation Pvt. Ltd. All Rights Reserved....
|
45863
|
|
45870
|
NULL
|
0
|
2026-04-17T10:10:48.870469+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776420648870_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFileEditViewHistoryWindowHelpQ Search Jiminny SlackFileEditViewHistoryWindowHelpQ Search Jiminny IncJiminny ...& jiminn... & 18= Unreads• MessagesMore~E ThreadsDMs6d Huddles• Drafts & senti8 DirectoriesAchivityAb External connectionsFiles Starred8 jiminny-x-integrati...& platform-inner-teamMore# Channels# ai-chapter# alertssrhackend#: confusion-clinic# curiosity_lab# engineering# frontend# general# infra-changes# jiminny-bg# platform-ticketsac broducr auncnesi# random# releases# sofia-office# support# thank-vous# the_people_of_jimi...# jiminny_social &• Direct messages8. Nikolay Nikolov º!3 Aneliya Angelova, ...P. Galya Dimitrova EStoyan Tanev€. Vasil VasilevNikolay IvanovP. Aneliya AngelovaP Ves&. Steliyan Georgiev::AppsJira CloudoastGoogle Cale..December 15th, 2025crosePartners as vart ur vur roundingCohort for Self-Integrations.Read more here: https://self-integration.getmembrane.com.Check out self-integrationmanifesto once in, if you'reinterested in the vision and whywe are working on thisReply or react a if you'reinterested - we're happy towalk vou throuen it over a vervSnon calllYesterdavyLukas Kovalik *11:04 АМHi guys, we have one issue weusea to nave derore regarainginecutnorsarontor cono ckv.When the clients go through allsteps and login it just returnshim back to the login screen. Ibelieve this is the reference tothe previous conversationhttps://jiminny.slack.com/archives/CO7RAC4U86M/p1748957897141919. Could you please havea look if there is any change?e Lukas Kovalik1. There appears to be arecent change in thesok OAutn mecnanism.Wnen a new clientconnees to ine da.rormnusine cono, we neloneer receive a rromise(https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection)upon successful login.This functionality wasworking just a fewweeks ago.edlledThread in auminnv-x-integraton.app Jun sra. 2025 View messageĐ10 replies Lascreolylu external people are tromMembraneMessage & jiminny-x-integration-...AaThreadMembrane Al Assistant APP Yesterday at 1:49 PM(1 Ah. I see now! You're referring to the connection obiect feld in the APl responsenot wednook events. roure nignt- tneres deen a change trom ausconnectea toconnected in the APl response.Looking at your API response:• Old held: "disconnected": false• New field: |"connected": trueThis is a breaking change that affects the logic you built around checkingconnection status. The boolean logic is also inverted ( disconnected: false =connection is active, while connected: true = connection is active).Let me escalate this to the team to confirm when this change was introduced andwhether both fields are temporarily supported during a transition period, or if youneed to update your code immediately to use the connected field instead ofdisconnected.I've escalated this to our engineering team to get clarification on the API change.They'll get back to you shortly with details about when this change was introducedand guidance on now to nandle it in your codeMembrane Al Assistant APP Yesterday at 2:26 PM• Good news! l've investigated the API change and here's what I found:Both fields are still returned - no breaking change!The API response now includes both:connected: true (new canonical field)disconnected: false (deprecated but still returned for backwardComloaialo lITVIWhat happened:On April 3, 2026, we updated the platform to use (connected as the primary fieldinstead of disconnected for better code readability. However, to ensure backwardcompatibility, the API automatically derives and includes the (disconnected field inall responses (di sconnected = !connected ).What this means for you:Your existing code using di sconnected will continue to work• No immediate code changes are requiredYou can migrate to using connected whenever convenientThe disconnected field is marked as deprecated but there's no removalameline announcedlWhen you do migrate, remember the logic is inverted:disconnected: false = connected: true (connection is active)(disconnected: true = [connected: false (connection needs re-authentication10 external people are from Membranemvou ook arine oav oae apove tnereis no e sconnecce so ts not oackwarecompatible. Is there scenario where | wou d receive disconnected instead ofconnectea. Aiso can we revent ine connection window Dack that was tnere deroreiupgraded the connector? We don't want to show Connect via Membrane,_ Also send to jiminny-x-integration-appAaUpdate your informationiofia (UTC +03:00)BES SPOKEN DURING CALLSPOKEN LANGUAGEnited Kinedomge Isn e detected we ll deraulato this oneiguageMSYNOSFTINGZoho CRMSign in with Zoho CRMImport Calendar Meetings*G Sign in with GoogleLet's Get Started!{ Support Daily - in 1h 50 mA100% CS•Fri 17 Apr 13:10:48AutomatedRenortsCommandTestDebugging OpportunitUpdate Connection Lo+0 •se in what we accept as connection object and it seemswas replaced be connection.connected. Ah, I see now! You're referring to theue ariresvonse, not webnook evenlis. vouve ten - meles veell d chlallgenectea in the Ar response.Talsethat affects the logic you built around checking connection status. Thetea olsconnecteo. Talse - connection is active, whlle connectee. true -team to confirm when this change was introduced and whether both fieldsuncerstano tne current sarehiaWloonger in the response (only connected exists), update both to use connectednented alternauives+1 -3+1 -2nnection && connection?.connected = true (positive check, removed discoommentee linesconnection || connection.connected = false (negative check, replaced disO il •ll be the response willt 4.6Reject allAccept allW Windsurf Teams 170:1 (18 chars) UTF-8 2 spaces ®...
|
NULL
|
-5283769414142173564
|
NULL
|
visual_change
|
ocr
|
NULL
|
SlackFileEditViewHistoryWindowHelpQ Search Jiminny SlackFileEditViewHistoryWindowHelpQ Search Jiminny IncJiminny ...& jiminn... & 18= Unreads• MessagesMore~E ThreadsDMs6d Huddles• Drafts & senti8 DirectoriesAchivityAb External connectionsFiles Starred8 jiminny-x-integrati...& platform-inner-teamMore# Channels# ai-chapter# alertssrhackend#: confusion-clinic# curiosity_lab# engineering# frontend# general# infra-changes# jiminny-bg# platform-ticketsac broducr auncnesi# random# releases# sofia-office# support# thank-vous# the_people_of_jimi...# jiminny_social &• Direct messages8. Nikolay Nikolov º!3 Aneliya Angelova, ...P. Galya Dimitrova EStoyan Tanev€. Vasil VasilevNikolay IvanovP. Aneliya AngelovaP Ves&. Steliyan Georgiev::AppsJira CloudoastGoogle Cale..December 15th, 2025crosePartners as vart ur vur roundingCohort for Self-Integrations.Read more here: https://self-integration.getmembrane.com.Check out self-integrationmanifesto once in, if you'reinterested in the vision and whywe are working on thisReply or react a if you'reinterested - we're happy towalk vou throuen it over a vervSnon calllYesterdavyLukas Kovalik *11:04 АМHi guys, we have one issue weusea to nave derore regarainginecutnorsarontor cono ckv.When the clients go through allsteps and login it just returnshim back to the login screen. Ibelieve this is the reference tothe previous conversationhttps://jiminny.slack.com/archives/CO7RAC4U86M/p1748957897141919. Could you please havea look if there is any change?e Lukas Kovalik1. There appears to be arecent change in thesok OAutn mecnanism.Wnen a new clientconnees to ine da.rormnusine cono, we neloneer receive a rromise(https://console.integration.app/ref/sdk/classes/IntegrationAccessor.html#openNewConnection)upon successful login.This functionality wasworking just a fewweeks ago.edlledThread in auminnv-x-integraton.app Jun sra. 2025 View messageĐ10 replies Lascreolylu external people are tromMembraneMessage & jiminny-x-integration-...AaThreadMembrane Al Assistant APP Yesterday at 1:49 PM(1 Ah. I see now! You're referring to the connection obiect feld in the APl responsenot wednook events. roure nignt- tneres deen a change trom ausconnectea toconnected in the APl response.Looking at your API response:• Old held: "disconnected": false• New field: |"connected": trueThis is a breaking change that affects the logic you built around checkingconnection status. The boolean logic is also inverted ( disconnected: false =connection is active, while connected: true = connection is active).Let me escalate this to the team to confirm when this change was introduced andwhether both fields are temporarily supported during a transition period, or if youneed to update your code immediately to use the connected field instead ofdisconnected.I've escalated this to our engineering team to get clarification on the API change.They'll get back to you shortly with details about when this change was introducedand guidance on now to nandle it in your codeMembrane Al Assistant APP Yesterday at 2:26 PM• Good news! l've investigated the API change and here's what I found:Both fields are still returned - no breaking change!The API response now includes both:connected: true (new canonical field)disconnected: false (deprecated but still returned for backwardComloaialo lITVIWhat happened:On April 3, 2026, we updated the platform to use (connected as the primary fieldinstead of disconnected for better code readability. However, to ensure backwardcompatibility, the API automatically derives and includes the (disconnected field inall responses (di sconnected = !connected ).What this means for you:Your existing code using di sconnected will continue to work• No immediate code changes are requiredYou can migrate to using connected whenever convenientThe disconnected field is marked as deprecated but there's no removalameline announcedlWhen you do migrate, remember the logic is inverted:disconnected: false = connected: true (connection is active)(disconnected: true = [connected: false (connection needs re-authentication10 external people are from Membranemvou ook arine oav oae apove tnereis no e sconnecce so ts not oackwarecompatible. Is there scenario where | wou d receive disconnected instead ofconnectea. Aiso can we revent ine connection window Dack that was tnere deroreiupgraded the connector? We don't want to show Connect via Membrane,_ Also send to jiminny-x-integration-appAaUpdate your informationiofia (UTC +03:00)BES SPOKEN DURING CALLSPOKEN LANGUAGEnited Kinedomge Isn e detected we ll deraulato this oneiguageMSYNOSFTINGZoho CRMSign in with Zoho CRMImport Calendar Meetings*G Sign in with GoogleLet's Get Started!{ Support Daily - in 1h 50 mA100% CS•Fri 17 Apr 13:10:48AutomatedRenortsCommandTestDebugging OpportunitUpdate Connection Lo+0 •se in what we accept as connection object and it seemswas replaced be connection.connected. Ah, I see now! You're referring to theue ariresvonse, not webnook evenlis. vouve ten - meles veell d chlallgenectea in the Ar response.Talsethat affects the logic you built around checking connection status. Thetea olsconnecteo. Talse - connection is active, whlle connectee. true -team to confirm when this change was introduced and whether both fieldsuncerstano tne current sarehiaWloonger in the response (only connected exists), update both to use connectednented alternauives+1 -3+1 -2nnection && connection?.connected = true (positive check, removed discoommentee linesconnection || connection.connected = false (negative check, replaced disO il •ll be the response willt 4.6Reject allAccept allW Windsurf Teams 170:1 (18 chars) UTF-8 2 spaces ®...
|
45868
|
|
46012
|
NULL
|
0
|
2026-04-17T10:16:00.349977+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776420960349_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
NotionFileEditViewHistoryWindowHelp‹ $0 lhl • { Su NotionFileEditViewHistoryWindowHelp‹ $0 lhl • { Support Daily•in1h45m A ? 100%« 8Fri 17 Apr 13:15:59Zoho Accounts — WorkDOCKER•₴1DEV (-zsh)APP (-2create mode 100644 app/Component/AiAutomation/Servicecreate mode 100644app/Component/DealRisks/GroupDealRcreatemode 100644app/Component/MediaPipeline/Handlecreate mode 100644 app/Component/MediaPipeline/Handlecreate mode 100644app/Component/ParagraphBreaker/DTO.create mode 100644app/Component/ParagraphBreaker/Serdelete mode 100644 app/Component/Transcription/Listendelete mode 100644app/Component/Transcription/Serviccreate mode 100644app/Component/Transcription/Serviccreate mode 100644 app/Component/Transcription/Transccreate mode 100644 app/Component/Transcription/Transcdelete mode 100644app/Component/Transcription/V0/Tracreate mode 100644app/Console/Commands/Crm/Hubspot/Rcreate mode 100644 app/Console/Commands/Crm/SyncOppor-create mode 100644 app/Contracts/Crm/SyncableCrmObjeccreate mode 100644app/Events/Crm/RemoteCrmRecordDelecreate mode 100644 app/Listeners/Crm/RemoteCrmRecordDcreate mode 100644 app/Services/Activity/HubSpot/Redicreate mode 100644 app/Services/Activity/HubSpot/Zoomcreate mode 100644 app/Services/Crm/CrmObjects/Validacreate mode 100644 contrib/tmp/hubspot-associations-Ucreate mode 100644 contrib/tmp/hubspot-associations-Ucreate mode 100644 contrib/tmp/hubspot-associations-U:create mode 100644 contrib/tmp/hubspot-associations-U:create mode 100644 database/migrations/2026_04_14_140delete mode 100644 resources/views/pdf/transcription.create mode 100644 tests/Unit/Component/AiAutomation/create mode 100644 tests/Unit/Component/MediaPipeline.create mode 100644 tests/Unit/Component/MediaPipelinecreate mode 100644 tests/Unit/Component/ParagraphBreadelete mode 100644 tests/Unit/Component/Transcriptioncreate mode 100644 tests/Unit/Component/Transcriptiondelete mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Component/Transcription,delete mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Guards/SsoTest.phpcreate mode 100644 tests/Unit/Jobs/Mailbox/SyncInboxTcreate mode 100644 tests/Unit/Listeners/Crm/RemoteCrmlcreate mode 100644 tests/Unit/Services/Activity/HubSpcreate mode 100644 tests/Unit/Services/Activity/Meeticreate mode 100644 tests/Unit/Services/Crm/CrmObjects,lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/appSwitched to a new branch 'JY-20692-fix-integration-appukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/appaccounts.zoho.eu/oauth/v2/auth?client_id=1000.20TYIF96EC5MJE90E7FUVE9Y7WTWSR&redirect_uri=https%3A%2F%2Fapi.getmembrar #=Jiminny Integ...20H0MembraneMembrane would like to access the following information.@ CRMJiminny Inc• Perform CRUD operations on the modules• Full access to Read, Create, Update and Delete user data in your organizationGroup scope to perform CRUD operations on metadata• get org dataFull access to ZohoCRM notificationsTo get the pipeline along with associated stagesget profilesTo read, create, update and delete global picklist• To fetch data using CRM Object Query Language COQLl allow Membrane to access the above data from my Zoho account.AcceptReject© 2026, Zoho Corporation Pvt. Ltd. All Rights Reserved....
|
NULL
|
6691585886940177134
|
NULL
|
click
|
ocr
|
NULL
|
NotionFileEditViewHistoryWindowHelp‹ $0 lhl • { Su NotionFileEditViewHistoryWindowHelp‹ $0 lhl • { Support Daily•in1h45m A ? 100%« 8Fri 17 Apr 13:15:59Zoho Accounts — WorkDOCKER•₴1DEV (-zsh)APP (-2create mode 100644 app/Component/AiAutomation/Servicecreate mode 100644app/Component/DealRisks/GroupDealRcreatemode 100644app/Component/MediaPipeline/Handlecreate mode 100644 app/Component/MediaPipeline/Handlecreate mode 100644app/Component/ParagraphBreaker/DTO.create mode 100644app/Component/ParagraphBreaker/Serdelete mode 100644 app/Component/Transcription/Listendelete mode 100644app/Component/Transcription/Serviccreate mode 100644app/Component/Transcription/Serviccreate mode 100644 app/Component/Transcription/Transccreate mode 100644 app/Component/Transcription/Transcdelete mode 100644app/Component/Transcription/V0/Tracreate mode 100644app/Console/Commands/Crm/Hubspot/Rcreate mode 100644 app/Console/Commands/Crm/SyncOppor-create mode 100644 app/Contracts/Crm/SyncableCrmObjeccreate mode 100644app/Events/Crm/RemoteCrmRecordDelecreate mode 100644 app/Listeners/Crm/RemoteCrmRecordDcreate mode 100644 app/Services/Activity/HubSpot/Redicreate mode 100644 app/Services/Activity/HubSpot/Zoomcreate mode 100644 app/Services/Crm/CrmObjects/Validacreate mode 100644 contrib/tmp/hubspot-associations-Ucreate mode 100644 contrib/tmp/hubspot-associations-Ucreate mode 100644 contrib/tmp/hubspot-associations-U:create mode 100644 contrib/tmp/hubspot-associations-U:create mode 100644 database/migrations/2026_04_14_140delete mode 100644 resources/views/pdf/transcription.create mode 100644 tests/Unit/Component/AiAutomation/create mode 100644 tests/Unit/Component/MediaPipeline.create mode 100644 tests/Unit/Component/MediaPipelinecreate mode 100644 tests/Unit/Component/ParagraphBreadelete mode 100644 tests/Unit/Component/Transcriptioncreate mode 100644 tests/Unit/Component/Transcriptiondelete mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Component/Transcription,delete mode 100644 tests/Unit/Component/Transcription.create mode 100644 tests/Unit/Guards/SsoTest.phpcreate mode 100644 tests/Unit/Jobs/Mailbox/SyncInboxTcreate mode 100644 tests/Unit/Listeners/Crm/RemoteCrmlcreate mode 100644 tests/Unit/Services/Activity/HubSpcreate mode 100644 tests/Unit/Services/Activity/Meeticreate mode 100644 tests/Unit/Services/Crm/CrmObjects,lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/appSwitched to a new branch 'JY-20692-fix-integration-appukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/appaccounts.zoho.eu/oauth/v2/auth?client_id=1000.20TYIF96EC5MJE90E7FUVE9Y7WTWSR&redirect_uri=https%3A%2F%2Fapi.getmembrar #=Jiminny Integ...20H0MembraneMembrane would like to access the following information.@ CRMJiminny Inc• Perform CRUD operations on the modules• Full access to Read, Create, Update and Delete user data in your organizationGroup scope to perform CRUD operations on metadata• get org dataFull access to ZohoCRM notificationsTo get the pipeline along with associated stagesget profilesTo read, create, update and delete global picklist• To fetch data using CRM Object Query Language COQLl allow Membrane to access the above data from my Zoho account.AcceptReject© 2026, Zoho Corporation Pvt. Ltd. All Rights Reserved....
|
NULL
|
|
46013
|
NULL
|
0
|
2026-04-17T10:16:01.377922+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776420961377_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFileEditViewJiminny…..DMsUnreads€ Threads6 Hu SlackFileEditViewJiminny…..DMsUnreads€ Threads6 Huddles• Drafts & sent:8 DirectoriesAchivityEh External connections* Starred& jiminny-x-integrati..• platform-inner-teamMorelChannels# ai-chapter# alerts# backend# contusion-clinic# curiosity lab# engineering# frontendi# general# infra-changes# jiminny-bg#: olatform-tickets# product_launchesuc random# releases# support# thank-yous# the_people_of_jimi...? Direct messages8. Nikolay Nikolov '®!3 Aneliya Angelova, ,P. Galya Dimitrova Co Stoyan Tanev€. Vasil Vasilev. Nikolay Ivanov0. Aneliya Angelova@. VesR. Steliyan Georgiev# Apps6 Jira Cloud® ToastHistoryWindowHelpSearch Jiminny Inc8 jiminn... & 18Thread• MessagesDecember 15th, 2025closePartiers as part vr vur roundingCohort for Self-Integrations.Read more here: https://self-integration.getmembrane.com.Check out self-integrationmanifesto once in, if you'reinterested in the vision and whywe are working on this)Reply or react a lly if you'reinterested - we're happy towalk vou ihrouen it over a vervSnon calllSet a statusWhat's your status?Looking at your API response:• Old field:talse• New field: |"connected"!trueInis s a orcakine cnance inararees tne lo? c vou pullt croune creckiineconnecuon status. Ine doolean logic is aiso Invertea (alfalse=connection is active, while |conted: true = connection is active).Let me escalate this to the team to confirm when this change was introduced andwhether both fields are temporarily supported during a transition period, or if youneed to update your code immediately to use the connected field instead ofdisconnected.I've escalated this to our engineering team to get clarification on the API change.They'll get back to you shortly with details about when this change was introducedand guidance on how to handle it in your code.Membrane Al Assistant APPresteludy dl 4.40 FIMthe API change and here's what I found:Xno breaking change!les both:anonical field)eprecated but still returned for backwardRecentOl - 1 hourFor Jiminny Incd the platform to use connected as the primary fieldbetter code readability. However, to ensure backwardatically derives and includes the disconnected field inIn a meeting - 1 hounIravelling — lodayvurscr -00ayVacationing - Don't clearWorking from Cambridge - Todaycusconnectec wll conanue to workiees are reculreaconnectea wnenever convenienuis marked as deprecated but there's no removalAutomatically updates31 In a meeting - Google Calendar updates are onhber the logic is inverted:connected: true (connection is active)onnected: false (connection needs re-CancelSaveove there is no disconnected so it is not backwardcompatible. Is there scenario where I would receive disconnected instead ofTip: Open this faster with 2 + Shift + Yd? Also can we revert the connection window back that was there before !uoeraded the connector? We don't want to show Connect via Membraneupon successful login.3 files@ Download allThis functionality wasworking just a few10weeks ago.Thread in & jiminny-x-integration-Đ11 replies Last repl ...10 external people are from MembraneIu external people are tromMembraneReply..Message &jiminny-x-integration-._ Also send to @ jiminny-x-integration-app, 50 ll{ Support Daily • in 1h 44 mA100% C4Fri 17 Apr 13:16:01AX Translate to English XnShareve and delete global picklisteM Object Query Language COQL¡ccess tne aoove cala Trom my Lono account.Rejectв 0=xcing your Zoho CRM accounting your Zoho CRM account...
|
NULL
|
925882453358760693
|
NULL
|
visual_change
|
ocr
|
NULL
|
SlackFileEditViewJiminny…..DMsUnreads€ Threads6 Hu SlackFileEditViewJiminny…..DMsUnreads€ Threads6 Huddles• Drafts & sent:8 DirectoriesAchivityEh External connections* Starred& jiminny-x-integrati..• platform-inner-teamMorelChannels# ai-chapter# alerts# backend# contusion-clinic# curiosity lab# engineering# frontendi# general# infra-changes# jiminny-bg#: olatform-tickets# product_launchesuc random# releases# support# thank-yous# the_people_of_jimi...? Direct messages8. Nikolay Nikolov '®!3 Aneliya Angelova, ,P. Galya Dimitrova Co Stoyan Tanev€. Vasil Vasilev. Nikolay Ivanov0. Aneliya Angelova@. VesR. Steliyan Georgiev# Apps6 Jira Cloud® ToastHistoryWindowHelpSearch Jiminny Inc8 jiminn... & 18Thread• MessagesDecember 15th, 2025closePartiers as part vr vur roundingCohort for Self-Integrations.Read more here: https://self-integration.getmembrane.com.Check out self-integrationmanifesto once in, if you'reinterested in the vision and whywe are working on this)Reply or react a lly if you'reinterested - we're happy towalk vou ihrouen it over a vervSnon calllSet a statusWhat's your status?Looking at your API response:• Old field:talse• New field: |"connected"!trueInis s a orcakine cnance inararees tne lo? c vou pullt croune creckiineconnecuon status. Ine doolean logic is aiso Invertea (alfalse=connection is active, while |conted: true = connection is active).Let me escalate this to the team to confirm when this change was introduced andwhether both fields are temporarily supported during a transition period, or if youneed to update your code immediately to use the connected field instead ofdisconnected.I've escalated this to our engineering team to get clarification on the API change.They'll get back to you shortly with details about when this change was introducedand guidance on how to handle it in your code.Membrane Al Assistant APPresteludy dl 4.40 FIMthe API change and here's what I found:Xno breaking change!les both:anonical field)eprecated but still returned for backwardRecentOl - 1 hourFor Jiminny Incd the platform to use connected as the primary fieldbetter code readability. However, to ensure backwardatically derives and includes the disconnected field inIn a meeting - 1 hounIravelling — lodayvurscr -00ayVacationing - Don't clearWorking from Cambridge - Todaycusconnectec wll conanue to workiees are reculreaconnectea wnenever convenienuis marked as deprecated but there's no removalAutomatically updates31 In a meeting - Google Calendar updates are onhber the logic is inverted:connected: true (connection is active)onnected: false (connection needs re-CancelSaveove there is no disconnected so it is not backwardcompatible. Is there scenario where I would receive disconnected instead ofTip: Open this faster with 2 + Shift + Yd? Also can we revert the connection window back that was there before !uoeraded the connector? We don't want to show Connect via Membraneupon successful login.3 files@ Download allThis functionality wasworking just a few10weeks ago.Thread in & jiminny-x-integration-Đ11 replies Last repl ...10 external people are from MembraneIu external people are tromMembraneReply..Message &jiminny-x-integration-._ Also send to @ jiminny-x-integration-app, 50 ll{ Support Daily • in 1h 44 mA100% C4Fri 17 Apr 13:16:01AX Translate to English XnShareve and delete global picklisteM Object Query Language COQL¡ccess tne aoove cala Trom my Lono account.Rejectв 0=xcing your Zoho CRM accounting your Zoho CRM account...
|
46011
|
|
46107
|
NULL
|
0
|
2026-04-17T10:21:19.662480+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776421279662_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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.014583333,"height":0.025555555},"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 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 });\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 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 });\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}]...
|
6089929419086115132
|
-8178086448081891036
|
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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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
|
|
46108
|
NULL
|
0
|
2026-04-17T10:21:19.669989+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776421279669_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorToo PhpStormFileEditViewNavigateCodeLaravelRefactorToolsWindowHelpFV faVsco.s v99 JY-20692-fix-integration-app-[API_KEY] v© AutomatedReportsService.php© SendReportJob.php© SendReportMailJob.phpC ReportController.phpTokenBuilder.phpC TeamSetupController.phpphp api.php• Filesystem.php© Team.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.php© RequestGenerateReportJob.phpOpportunitySyncTrait.phpC Opportunity.phpT InteractsWithPivotTable.phgC OpportunityUpdated.phpC OpportunityStageUpdated.php= custom.log= laravel.logValidationOVov D Repositoryv D OnDemandActivitySt© Criteria.php© TranscriptionKeywor> D TeamSettingspnp nelpers.ongInitialFrontendState.php©Jiminny.php©Plan.php© Serializer.php© TeamScimDetails.phpbootstrapD buildO config[Mcontrib> M database> M docsv _ front-end> O.vscode> D.yarn› D coverage› D node_modules› D publicO resourcesscriptssrcD_mocks_DappsD assets_ components> D AiReportsv O connect> D_mocks_> D_tests_0 connect.lessV connect.vuecashboardDeallnsichts> MerrorPagesexoon-oonalextension-installedD Invitation• JoinConferencelayout• LiveCoach• LockedD loginMeetingConsentmobileMonboard> M mocks_› testsV MobileAppDownk0 Onboard.lessV Onboard.vuets useProvidersSync> D ondemand< Hs local liminnyalocalnostA SF [jiminny@localhost]CP scratch_1.json4 console [EUl© CrmEntityRepository.phpconnect.vue xV Onboard.vuefi crm_configurations (EU]A console [STAGING]c) FventServiceProvider.onn© OpportunityPendingAiAnalysisAfterStageChanged.php© RunOpportunityAiAnalysis.php© ProcessAiAutomationAnalysisResults.php© ImportOpportunityBatch.php101138143148A console [PROD]<script>methods: {async integrationAppOnClickO) €const connection = await integrationAppoccomoceaprevurmeutduller taloe3):( ImportBatchJobTrait.php(C) Service.phpconsole.Log(' [IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));cachedStagesCcW151trait OpportunitySyncTraitA33 X2 X1911041531018102810291030private function resolveForecastCategory(?string $forecastCategory): stringf.. 154155arsuate funetion AnponeExtenalFlez Data (onnay Soropertie, int Sopontunt ty ,59)ecrurtelas - echis >gecupporcunttysyncab terte tastee-158159$this->importOpportunityCrmFieldData($properties, $crmFields,Sopportunity] 160// [IntegrationApp] openNewConnection resolved: {"id": "69e0b41a67d0068c2ca0b48e","name":"Zoho CRM","userId":"1ece66c8-feb1-4df1-b321-21607daf4623","tenantId": "69e0b3faef3e7b6248189289","isTest" : false,"connected": true,"state": "READY","errors": [],10331034103510481049105010511052=161162Lusaees/**1 usage— 105private function importOpportunityContacts(Opportunity Sopportunity, array $as: 164165166* Sync opportunity contacts using differential approachеxtеrарро ое едоо ее"authOptionKey" : """createdAt":"2026-04-16T10:04:10.420Z","updatedAt": "2026-04-16T10:04:10.575Z","retryAttempts" :0,"isDeactivated" : false* This compares current vs new associations and only makes necessary changes16810/01J0/1107710781079108010931074111511331134private tunccion syncupporcunttyconcactsultterenclal upporcunicy фopportunitуnprivate function getcurrentContactCrmIds(Opportunity $opportunity): array{...71701 usageprivate function logContactAssociationChanges(Opportunity Sopportunity,array $currentContactCrmIds,array $contactAssociations,array $contactsToAdd,array +concacustokemove): void {...}1721731741 usageprivate function removeContactAssociations(Opportunity Sopportunity, array $cor 182100184Tusaueprivate function addContactAssociations (Opportunity $opportunity, array $contac 1851 usageprivate function attachSingleContact (Opportunity Sopportunity, string Scrmid -189It conneccson oxconneccson:.ursconnecteu === Talse comneccson:.connec Accent LRotech tif (connection d& connection.connected === true) {1i (connection se connection.cosconnectedtrueseif (connection && connection.disconnected !== true && connection.connected !== false) €console.log('[IntegrationApp] connection condition matched'D;try{const saveRequest = await axios.post("Lapi/v1/integration-app-connect".if (saveRequest.data && saveRequest.data.success === true) <** If all is aood refresh the page here *window.Location = "/dashboard";recurnthrow new Error(saveRequest.data.message);f catch (error) (console.log(error);showSnackbarError(normalizeError(error)):-190private function performContactAttachment(Opportunity $opportunity, Contact Sc191</script>115011/11 usage«style module lang="less" src="./connect.less"></style>41/0private function logAddedContacts(Opportunity $opportunity, array $contactsAddeX Reject File 1 % @+ 2 of 2 files →Shortcuts conflicts: Clone Caret Above and 1 more shortcut conflict with macOS shortcuts. Modify these shortcuts or change macOS system settings. // Modify Shortcuts // Don't Show Again (58 minutes ago){ Support Daily • in 1h 39 mA100% C•Fri 17 Apr 13:21:19AutomatedRenortsCommandTestCascadeAutomated Report RetiDebugging OpportunitUpdate Connection Loso why did youvue#L170 connection?.disconnected in cone place andconnection.disconnected on otherwNo functional reason — it's inconsistent. conect.vue already has a connection && guard so the optionalchalnine Is recuneanttnere. Ler me alien ootn tlles lo use plain dot notation since connection Isalwavs checked for existence first• connect.vue+1 -1Both files now consistently use plain dot notation — connection is always null-checked before accessingits properties.ta all**2 files +3-5›Ask anything (24L)+ ‹> CodeClaude Sonnet 4.6Reject allAccept allwinasun leams171:67 UTF-8( 2 spaces...
|
NULL
|
-5817970843246167218
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorToo PhpStormFileEditViewNavigateCodeLaravelRefactorToolsWindowHelpFV faVsco.s v99 JY-20692-fix-integration-app-[API_KEY] v© AutomatedReportsService.php© SendReportJob.php© SendReportMailJob.phpC ReportController.phpTokenBuilder.phpC TeamSetupController.phpphp api.php• Filesystem.php© Team.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.php© RequestGenerateReportJob.phpOpportunitySyncTrait.phpC Opportunity.phpT InteractsWithPivotTable.phgC OpportunityUpdated.phpC OpportunityStageUpdated.php= custom.log= laravel.logValidationOVov D Repositoryv D OnDemandActivitySt© Criteria.php© TranscriptionKeywor> D TeamSettingspnp nelpers.ongInitialFrontendState.php©Jiminny.php©Plan.php© Serializer.php© TeamScimDetails.phpbootstrapD buildO config[Mcontrib> M database> M docsv _ front-end> O.vscode> D.yarn› D coverage› D node_modules› D publicO resourcesscriptssrcD_mocks_DappsD assets_ components> D AiReportsv O connect> D_mocks_> D_tests_0 connect.lessV connect.vuecashboardDeallnsichts> MerrorPagesexoon-oonalextension-installedD Invitation• JoinConferencelayout• LiveCoach• LockedD loginMeetingConsentmobileMonboard> M mocks_› testsV MobileAppDownk0 Onboard.lessV Onboard.vuets useProvidersSync> D ondemand< Hs local liminnyalocalnostA SF [jiminny@localhost]CP scratch_1.json4 console [EUl© CrmEntityRepository.phpconnect.vue xV Onboard.vuefi crm_configurations (EU]A console [STAGING]c) FventServiceProvider.onn© OpportunityPendingAiAnalysisAfterStageChanged.php© RunOpportunityAiAnalysis.php© ProcessAiAutomationAnalysisResults.php© ImportOpportunityBatch.php101138143148A console [PROD]<script>methods: {async integrationAppOnClickO) €const connection = await integrationAppoccomoceaprevurmeutduller taloe3):( ImportBatchJobTrait.php(C) Service.phpconsole.Log(' [IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));cachedStagesCcW151trait OpportunitySyncTraitA33 X2 X1911041531018102810291030private function resolveForecastCategory(?string $forecastCategory): stringf.. 154155arsuate funetion AnponeExtenalFlez Data (onnay Soropertie, int Sopontunt ty ,59)ecrurtelas - echis >gecupporcunttysyncab terte tastee-158159$this->importOpportunityCrmFieldData($properties, $crmFields,Sopportunity] 160// [IntegrationApp] openNewConnection resolved: {"id": "69e0b41a67d0068c2ca0b48e","name":"Zoho CRM","userId":"1ece66c8-feb1-4df1-b321-21607daf4623","tenantId": "69e0b3faef3e7b6248189289","isTest" : false,"connected": true,"state": "READY","errors": [],10331034103510481049105010511052=161162Lusaees/**1 usage— 105private function importOpportunityContacts(Opportunity Sopportunity, array $as: 164165166* Sync opportunity contacts using differential approachеxtеrарро ое едоо ее"authOptionKey" : """createdAt":"2026-04-16T10:04:10.420Z","updatedAt": "2026-04-16T10:04:10.575Z","retryAttempts" :0,"isDeactivated" : false* This compares current vs new associations and only makes necessary changes16810/01J0/1107710781079108010931074111511331134private tunccion syncupporcunttyconcactsultterenclal upporcunicy фopportunitуnprivate function getcurrentContactCrmIds(Opportunity $opportunity): array{...71701 usageprivate function logContactAssociationChanges(Opportunity Sopportunity,array $currentContactCrmIds,array $contactAssociations,array $contactsToAdd,array +concacustokemove): void {...}1721731741 usageprivate function removeContactAssociations(Opportunity Sopportunity, array $cor 182100184Tusaueprivate function addContactAssociations (Opportunity $opportunity, array $contac 1851 usageprivate function attachSingleContact (Opportunity Sopportunity, string Scrmid -189It conneccson oxconneccson:.ursconnecteu === Talse comneccson:.connec Accent LRotech tif (connection d& connection.connected === true) {1i (connection se connection.cosconnectedtrueseif (connection && connection.disconnected !== true && connection.connected !== false) €console.log('[IntegrationApp] connection condition matched'D;try{const saveRequest = await axios.post("Lapi/v1/integration-app-connect".if (saveRequest.data && saveRequest.data.success === true) <** If all is aood refresh the page here *window.Location = "/dashboard";recurnthrow new Error(saveRequest.data.message);f catch (error) (console.log(error);showSnackbarError(normalizeError(error)):-190private function performContactAttachment(Opportunity $opportunity, Contact Sc191</script>115011/11 usage«style module lang="less" src="./connect.less"></style>41/0private function logAddedContacts(Opportunity $opportunity, array $contactsAddeX Reject File 1 % @+ 2 of 2 files →Shortcuts conflicts: Clone Caret Above and 1 more shortcut conflict with macOS shortcuts. Modify these shortcuts or change macOS system settings. // Modify Shortcuts // Don't Show Again (58 minutes ago){ Support Daily • in 1h 39 mA100% C•Fri 17 Apr 13:21:19AutomatedRenortsCommandTestCascadeAutomated Report RetiDebugging OpportunitUpdate Connection Loso why did youvue#L170 connection?.disconnected in cone place andconnection.disconnected on otherwNo functional reason — it's inconsistent. conect.vue already has a connection && guard so the optionalchalnine Is recuneanttnere. Ler me alien ootn tlles lo use plain dot notation since connection Isalwavs checked for existence first• connect.vue+1 -1Both files now consistently use plain dot notation — connection is always null-checked before accessingits properties.ta all**2 files +3-5›Ask anything (24L)+ ‹> CodeClaude Sonnet 4.6Reject allAccept allwinasun leams171:67 UTF-8( 2 spaces...
|
NULL
|
|
46239
|
NULL
|
0
|
2026-04-17T10:26:28.754261+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776421588754_m1.jpg...
|
PhpStorm
|
faVsco.js – SF [jiminny@localhost]
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp(ahl= Support Daily • in 1h 34 m100% C47APP (-zsh)X4-zshDOCKER• ₴1DEV (-zsh)882APP (-zsh)|X3-zsh./public/vue-assets/assets/exports-D1lmea40.js:./public/vue-assets/assets/playlists-Yn3gKP2z.js./public/vue-assets/assets/callScoringTemplates-zeRn40ul.js./public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js:./public/vue-assets/assets/onboard-BqxRx4zw.js./public/vue-assets/assets/StatusBadge-C2H2xj9g.js:/public/vue-assets/assets/kiosk-BDlz4mpq.js:./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-BnVE19yc.js../public/vue-assets/assets/ListView-CN2ZtlC7.js../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js./public/vue-assets/assets/dashboard-XCNXJG27.js./public/vue-assets/assets/emoji-input-CSq870Vy.js./public/vue-assets/assets/AppButton-D3qMd0Dr.js./public/vue-assets/assets/sentry-DMzthP2u.js../public/vue-assets/assets/OrgSettingsLayout-DYz0he6q.js../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js../public/vue-assets/assets/playback-DRpCBZ09.js../public/vue-assets/assets/index.module-Bjlhgfd1.js../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js:./public/vue-assets/assets/team-insights-CPpPTAKV.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js../public/vue-assets/assets/live-Cfb59XkF.js./public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-1Grk9P2e.js../public/vue-assets/assets/logged-in-layout-DArs1VTa.jsО 88547.84kB48.28kB55.13kB61.28kB62.98kB63.11kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59kB120.67kB128.71kB129.28kB133.44kB164.28kB176.33kB180.40kB198.79 kВ218.14kB264.94kB298.57kB307.13 kB343.99kB367.43kB689.63 kB825.23kB1,402.70 kB[PLUGIN_TIMINGS] Warning: Your build spent significant time in plugins. Here is a breakdown:- vite:css (61%)- vite-plugin-externals (12%)- vite:vue (9%)- sentry-vite-plugin (8%)- vite:worker (6%)See [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ O* Review screenp...gzip:16.46kBgz1p:15.07kBgzip:13.28kB9z1p:20.09kB921p:18.88kBgzip:21.85kBgzip:22.96kBgzip:22.65kBgz1p:27.46kBgzip:28.16kBgz1p:33.78kBgzip:38.71kBgzip:34.16kB9z1p:40.05kB9z1p:36.72kBgzip:43.03kBgzip:52.24kBgzip:gzip:56.15kB67.85 kBgzip:61.85 kBgz1p:64.16 kBgz1p:60.31 kBgzip:77.22 kBgz1p:103.86 kB9z1p:84.90 kB9z1p:97.05 kB921p:202.81 kBgzip:72.55 kВ921p: 438.08 kB• [EMAIL]:294.48kBmap:153..25kBmap:65.85kBmap:239.59kBmap:219.27 kBmap:201.38kBmap:244.72kBmap:300.68kBmap:map:3,452..35kB292.79kBmap:308..10kBmap:500.60kBmap:258.56kBmap:410.48kBmap:266.15kBmap:516.67kBmap:831.82kBmap:623.43kBmap:836.88 kBmap:684.87 kBmap: 1,108.20 kBmap:475.61kBmap:959.96kBmap: 1,245.28kBmap:849.05 kBmap:792.41kBmap: 3,016.64 kBmap:436.62 kBmap: 6,283.55kB• 878Fri 17 Apr 13:26:281₴81ec2-user@ip-10-...• 88APP...
|
NULL
|
-5596740301088824425
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelp(ahl= Support Daily • in 1h 34 m100% C47APP (-zsh)X4-zshDOCKER• ₴1DEV (-zsh)882APP (-zsh)|X3-zsh./public/vue-assets/assets/exports-D1lmea40.js:./public/vue-assets/assets/playlists-Yn3gKP2z.js./public/vue-assets/assets/callScoringTemplates-zeRn40ul.js./public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js:./public/vue-assets/assets/onboard-BqxRx4zw.js./public/vue-assets/assets/StatusBadge-C2H2xj9g.js:/public/vue-assets/assets/kiosk-BDlz4mpq.js:./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-BnVE19yc.js../public/vue-assets/assets/ListView-CN2ZtlC7.js../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js./public/vue-assets/assets/dashboard-XCNXJG27.js./public/vue-assets/assets/emoji-input-CSq870Vy.js./public/vue-assets/assets/AppButton-D3qMd0Dr.js./public/vue-assets/assets/sentry-DMzthP2u.js../public/vue-assets/assets/OrgSettingsLayout-DYz0he6q.js../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js../public/vue-assets/assets/playback-DRpCBZ09.js../public/vue-assets/assets/index.module-Bjlhgfd1.js../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js:./public/vue-assets/assets/team-insights-CPpPTAKV.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js../public/vue-assets/assets/live-Cfb59XkF.js./public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-1Grk9P2e.js../public/vue-assets/assets/logged-in-layout-DArs1VTa.jsО 88547.84kB48.28kB55.13kB61.28kB62.98kB63.11kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59kB120.67kB128.71kB129.28kB133.44kB164.28kB176.33kB180.40kB198.79 kВ218.14kB264.94kB298.57kB307.13 kB343.99kB367.43kB689.63 kB825.23kB1,402.70 kB[PLUGIN_TIMINGS] Warning: Your build spent significant time in plugins. Here is a breakdown:- vite:css (61%)- vite-plugin-externals (12%)- vite:vue (9%)- sentry-vite-plugin (8%)- vite:worker (6%)See [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ O* Review screenp...gzip:16.46kBgz1p:15.07kBgzip:13.28kB9z1p:20.09kB921p:18.88kBgzip:21.85kBgzip:22.96kBgzip:22.65kBgz1p:27.46kBgzip:28.16kBgz1p:33.78kBgzip:38.71kBgzip:34.16kB9z1p:40.05kB9z1p:36.72kBgzip:43.03kBgzip:52.24kBgzip:gzip:56.15kB67.85 kBgzip:61.85 kBgz1p:64.16 kBgz1p:60.31 kBgzip:77.22 kBgz1p:103.86 kB9z1p:84.90 kB9z1p:97.05 kB921p:202.81 kBgzip:72.55 kВ921p: 438.08 kB• [EMAIL]:294.48kBmap:153..25kBmap:65.85kBmap:239.59kBmap:219.27 kBmap:201.38kBmap:244.72kBmap:300.68kBmap:map:3,452..35kB292.79kBmap:308..10kBmap:500.60kBmap:258.56kBmap:410.48kBmap:266.15kBmap:516.67kBmap:831.82kBmap:623.43kBmap:836.88 kBmap:684.87 kBmap: 1,108.20 kBmap:475.61kBmap:959.96kBmap: 1,245.28kBmap:849.05 kBmap:792.41kBmap: 3,016.64 kBmap:436.62 kBmap: 6,283.55kB• 878Fri 17 Apr 13:26:281₴81ec2-user@ip-10-...• 88APP...
|
NULL
|
|
46240
|
NULL
|
0
|
2026-04-17T10:26:28.754267+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776421588754_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorToo PhpStormFileEditViewNavigateCodeLaravelRefactorToolsWindowHelpFV faVsco.s v99 JY-20692-fix-integration-app-[API_KEY] v© AutomatedReportsService.php© SendReportJob.php© SendReportMailJob.phpC ReportController.phpTokenBuilder.phpC TeamSetupController.phpphp api.php• Filesystem.php© Team.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.php© RequestGenerateReportJob.phpOpportunitySyncTrait.phpC Opportunity.phpT InteractsWithPivotTable.phgC OpportunityUpdated.phpC OpportunityStageUpdated.php= custom.log= laravel.logValidationOVov D Repositoryv D OnDemandActivitySt© Criteria.php© TranscriptionKeywor> D TeamSettingspnp nelpers.ongInitialFrontendState.php©Jiminny.php© Plan.php© Serializer.php© TeamScimDetails.phpbootstrapD buildO config[M contrib> M database> M docsv _ front-end> O.vscode> D.yarn› D coverage› D node_modules› D publicO resourcesscriptssrcD_mocks_DJappsD assets_ components> D AiReportsv O connect> D_mocks_> D_tests_0 connect.lessV connect.vuecashboardDeallnsichts> MerrorPagesexoon-oonalextension-installedD Invitation• JoinConferencelayout• LiveCoach• LockedD loginMeetingConsentmobileMonboard> M mocks_› testsV MobileAppDownk0 Onboard.lessV Onboard.vuets useProvidersSync> D ondemand< Hs local liminnyalocalnostA SF [jiminny@localhost]C scratch_1json xconnect.vue xV Onboard.vue4 console [EUl© CrmEntityRepository.phpfi crm_configurations (EU]A console [PROD]A console [STAGING]<script>101methods: {138async integrationAppOnCLickO) €c) FventService?rovider.onn© OpportunityPendingAiAnalysisAfterStageChanged.php143const connection = await integrationAppoccomoceaprevurmeutduller taloe© RunOpportunityAiAnalysis.php© ProcessAiAutomationAnalysisResults.php© ImportOpportunityBatch.php3):( ImportBatchJobTrait.php(C) Service.phpconsole.Log(' [IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));cachedStagesCcW151trait OpportunitySyncTraitA33 X2 X1911041531018private function resolveForecastCategory(?string $forecastCategory): stringf.. 15410281551029arsuate funetion AnponeExtenalFlez Data (onnay Soropertile, int Soppontunt ty ,59)1030ecrurtelas - echis >gecupporcunicyoyncab lertelaso).-158159$this->importOpportunityCrmFieldData($properties, $crmFields,Sopportunity] 160// [IntegrationApp] openNewConnection resolved: {"id": "69e0b41a67d0068c2ca0b48e","name":"Zoho CRM","userId":"1ece66c8-feb1-4df1-b321-21607daf4623","tenantId": "69e0b3faef3e7b6248189289","isTest" : false,"connected": true,"state": "READY","errors": [],1033=1611034162Lusaees— 1051035private function importOpportunityContacts(Opportunity Sopportunity, array $as: 16410481651049/**1661050* Sync opportunity contacts using differential approachеxtеrарро ое едоо ее"authOptionKey" : """createdAt":"2026-04-16T10:04:10.420Z","updatedAt": "2026-04-16T10:04:10.575Z","retryAttempts" :0,"isDeactivated" : false1051* This compares current vs new associations and only makes necessary changes16810521 usageprivate tunccion syncupporcunttyconcactsultterenclal upporcunicy фopportunitуn10/0private function getcurrentContactCrmIds(Opportunity $opportunity): arrayf...71701J0/110771078107910801721 usageprivate function logContactAssociationChanges(173Opportunity Sopportunity,174array $currentContactCrmIds,array $contactAssociations,array $contactsToAdd,array +concacustokemove): void {...}10931801811 usage1074private function removeContactAssociations(Opportunity Sopportunity, array $cor 1821115184Tusaue1116private function addContactAssociations (Opportunity $opportunity, array $contac 18511331 usage1134private function attachSingleContact (Opportunity Sopportunity, string Scrmid -18941/0It conneccson oxconneccson:.ursconnecteu === Talse comneccson:.connec Accent LRotech tif (connection d& connection.connected === true) {1if (connection a connection.disconnectedt oeeif (connection && connection.disconnected !== true && connection.connected !== false) €console.log('[IntegrationApp] connection condition matched');try{const saveRequest = await axios.post("Lapi/v1/integration-app-connect".if (saveRequest.data && saveRequest.data.success === true) <** If all is aood refresh the page here *window.Location = "/dashboard";return;|throw new Error(saveRequest.data.message);} catch (error) {console.log(error);showSnackbarError(normalizeError(error)):-190orivate Tunccion pertormconcactActachment upporcunity sopportunlcContact Sc,191</script>115011/11 usage«style module lang="less" src="./connect.less"></style>private function logAddedContacts(Opportunity $opportunity, array $contactsAddeX Reject File 1 % @+ 2 of 2 files →Shortcuts conflicts: Clone Caret Above and 1 more shortcut conflict with macOS shortcuts. Modify these shortcuts or change macOS system settings. // Modify Shortcuts // Don't Show Again (today 12:22))j Support Daily • in 1h 34 mA100% C•Fri 17 Apr 13:26:28AutomatedRenortsCommandTestCascadeAutomated Report RetiDebugging OpportunitUpdate Connection Loso why did youvue#L170 connection?.disconnected in cone place andconnection.disconnected on otherwNo functional reason — it's inconsistent. connect.vue already has a connection && guard so the optionalchalnine Is recuneanttnere. Ler me alien ootn tlles lo use plain dot notation since connection Isalwavs checked for existence first• connect.vue+1 -1Both files now consistently use plain dot notation — connection is always null-checked before accessingits properties.ta all**2 files +2-5>Ask anything (24L)+ ‹> CodeClaude Sonnet 4.6Reject allAccept allW Windsurf Teams179:20ulr-o2 spaces...
|
NULL
|
-3153063581759725570
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorToo PhpStormFileEditViewNavigateCodeLaravelRefactorToolsWindowHelpFV faVsco.s v99 JY-20692-fix-integration-app-[API_KEY] v© AutomatedReportsService.php© SendReportJob.php© SendReportMailJob.phpC ReportController.phpTokenBuilder.phpC TeamSetupController.phpphp api.php• Filesystem.php© Team.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.php© RequestGenerateReportJob.phpOpportunitySyncTrait.phpC Opportunity.phpT InteractsWithPivotTable.phgC OpportunityUpdated.phpC OpportunityStageUpdated.php= custom.log= laravel.logValidationOVov D Repositoryv D OnDemandActivitySt© Criteria.php© TranscriptionKeywor> D TeamSettingspnp nelpers.ongInitialFrontendState.php©Jiminny.php© Plan.php© Serializer.php© TeamScimDetails.phpbootstrapD buildO config[M contrib> M database> M docsv _ front-end> O.vscode> D.yarn› D coverage› D node_modules› D publicO resourcesscriptssrcD_mocks_DJappsD assets_ components> D AiReportsv O connect> D_mocks_> D_tests_0 connect.lessV connect.vuecashboardDeallnsichts> MerrorPagesexoon-oonalextension-installedD Invitation• JoinConferencelayout• LiveCoach• LockedD loginMeetingConsentmobileMonboard> M mocks_› testsV MobileAppDownk0 Onboard.lessV Onboard.vuets useProvidersSync> D ondemand< Hs local liminnyalocalnostA SF [jiminny@localhost]C scratch_1json xconnect.vue xV Onboard.vue4 console [EUl© CrmEntityRepository.phpfi crm_configurations (EU]A console [PROD]A console [STAGING]<script>101methods: {138async integrationAppOnCLickO) €c) FventService?rovider.onn© OpportunityPendingAiAnalysisAfterStageChanged.php143const connection = await integrationAppoccomoceaprevurmeutduller taloe© RunOpportunityAiAnalysis.php© ProcessAiAutomationAnalysisResults.php© ImportOpportunityBatch.php3):( ImportBatchJobTrait.php(C) Service.phpconsole.Log(' [IntegrationApp] openNewConnection resolved:', JSON.stringify(connection));cachedStagesCcW151trait OpportunitySyncTraitA33 X2 X1911041531018private function resolveForecastCategory(?string $forecastCategory): stringf.. 15410281551029arsuate funetion AnponeExtenalFlez Data (onnay Soropertile, int Soppontunt ty ,59)1030ecrurtelas - echis >gecupporcunicyoyncab lertelaso).-158159$this->importOpportunityCrmFieldData($properties, $crmFields,Sopportunity] 160// [IntegrationApp] openNewConnection resolved: {"id": "69e0b41a67d0068c2ca0b48e","name":"Zoho CRM","userId":"1ece66c8-feb1-4df1-b321-21607daf4623","tenantId": "69e0b3faef3e7b6248189289","isTest" : false,"connected": true,"state": "READY","errors": [],1033=1611034162Lusaees— 1051035private function importOpportunityContacts(Opportunity Sopportunity, array $as: 16410481651049/**1661050* Sync opportunity contacts using differential approachеxtеrарро ое едоо ее"authOptionKey" : """createdAt":"2026-04-16T10:04:10.420Z","updatedAt": "2026-04-16T10:04:10.575Z","retryAttempts" :0,"isDeactivated" : false1051* This compares current vs new associations and only makes necessary changes16810521 usageprivate tunccion syncupporcunttyconcactsultterenclal upporcunicy фopportunitуn10/0private function getcurrentContactCrmIds(Opportunity $opportunity): arrayf...71701J0/110771078107910801721 usageprivate function logContactAssociationChanges(173Opportunity Sopportunity,174array $currentContactCrmIds,array $contactAssociations,array $contactsToAdd,array +concacustokemove): void {...}10931801811 usage1074private function removeContactAssociations(Opportunity Sopportunity, array $cor 1821115184Tusaue1116private function addContactAssociations (Opportunity $opportunity, array $contac 18511331 usage1134private function attachSingleContact (Opportunity Sopportunity, string Scrmid -18941/0It conneccson oxconneccson:.ursconnecteu === Talse comneccson:.connec Accent LRotech tif (connection d& connection.connected === true) {1if (connection a connection.disconnectedt oeeif (connection && connection.disconnected !== true && connection.connected !== false) €console.log('[IntegrationApp] connection condition matched');try{const saveRequest = await axios.post("Lapi/v1/integration-app-connect".if (saveRequest.data && saveRequest.data.success === true) <** If all is aood refresh the page here *window.Location = "/dashboard";return;|throw new Error(saveRequest.data.message);} catch (error) {console.log(error);showSnackbarError(normalizeError(error)):-190orivate Tunccion pertormconcactActachment upporcunity sopportunlcContact Sc,191</script>115011/11 usage«style module lang="less" src="./connect.less"></style>private function logAddedContacts(Opportunity $opportunity, array $contactsAddeX Reject File 1 % @+ 2 of 2 files →Shortcuts conflicts: Clone Caret Above and 1 more shortcut conflict with macOS shortcuts. Modify these shortcuts or change macOS system settings. // Modify Shortcuts // Don't Show Again (today 12:22))j Support Daily • in 1h 34 mA100% C•Fri 17 Apr 13:26:28AutomatedRenortsCommandTestCascadeAutomated Report RetiDebugging OpportunitUpdate Connection Loso why did youvue#L170 connection?.disconnected in cone place andconnection.disconnected on otherwNo functional reason — it's inconsistent. connect.vue already has a connection && guard so the optionalchalnine Is recuneanttnere. Ler me alien ootn tlles lo use plain dot notation since connection Isalwavs checked for existence first• connect.vue+1 -1Both files now consistently use plain dot notation — connection is always null-checked before accessingits properties.ta all**2 files +2-5>Ask anything (24L)+ ‹> CodeClaude Sonnet 4.6Reject allAccept allW Windsurf Teams179:20ulr-o2 spaces...
|
NULL
|
|
46364
|
NULL
|
0
|
2026-04-17T10:31:19.600215+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776421879600_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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.014583333,"height":0.025555555},"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 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 });\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 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 });\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}]...
|
6089929419086115132
|
-8178086448081891036
|
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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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...
|
46362
|
|
46365
|
NULL
|
0
|
2026-04-17T10:31:20.687848+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776421880687_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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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.23320313,"top":1.0,"width":0.00859375,"height":0.0},"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.23320313,"top":1.0,"width":0.00859375,"height":0.0},"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.23320313,"top":1.0,"width":0.008203125,"height":0.0},"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 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 });\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 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 });\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}]...
|
6089929419086115132
|
-8178086448081891036
|
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() {
const integrationApp = new IntegrationAppClient({
token: this.crmToken,
});
const connection = await integrationApp
.integration(this.localProvider.name)
.openNewConnection({
showPoweredBy: false,
allowMultipleConnections: false,
});
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
|
|
46470
|
NULL
|
0
|
2026-04-17T10:36:37.925635+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776422197925_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelp>0 l4lSupport Daily - in 1h 24 m100% <47-zshAPP (-zsh)|X4-zshDOCKER881DEV (-zsh)APP (-zsh)X3./public/vue-assets/assets/ondemand-CE8XLH98.js:./public/vue-assets/assets/CrmLink-DKYsnHnx.js./public/vue-assets/assets/liquor-tree-COUefof4.js./public/vue-assets/assets/DealRiskList-3DSxnTNQ.js:./public/vue-assets/assets/AskAnything-BQ36E0GS.js:./public/vue-assets/assets/lib-CwM9toD2.js./public/vue-assets/assets/AppFormField-CqCXZwrk.js./public/vue-assets/assets/deal-view-B92qaj32.js:./public/vue-assets/assets/exports-D1lmea40.js../public/vue-assets/assets/playlists-u3UeUUz5.js../public/vue-assets/assets/callScoringTemplates-zeRn40ul.js../public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js•/public/vue-assets/assets/onboard-CCnYJmCP.js./public/vue-assets/assets/StatusBadge-CNGFZm8P.js./public/vue-assets/assets/kiosk-D6N5-ivP.js./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-jlkz65MZ.js../public/vue-assets/assets/ListView-DmKqYVi1.js:./public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js./public/vue-assets/assets/WelcomeLayout-B6wd32HG.js../public/vue-assets/assets/dashboard-BWoBIPKA.js:./public/vue-assets/assets/emoji-input-CSq87OVy.js../public/vue-assets/assets/AppButton-D3qMdODr.js../public/vue-assets/assets/sentry-icPKypZa.js:./public/vue-assets/assets/OrgSettingsLayout-BZ-JOgiH.js./public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js./public/vue-assets/assets/playback-DKsqp_al.js./public/vue-assets/assets/index.module-Bjlhgfd1.js./public/vue-assets/assets/intl-tel-input-BW4mv40Q.js:./public/vue-assets/assets/team-insights-D9P6ojjD.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js./public/vue-assets/assets/live-DWumv-QD.js../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-DiEs6xIb.js:./public/vue-assets/assets/logged-in-layout-Cq1ztH50.jsH8526.88kB27.91kB30.75kB34.39kB39.50kB39.69kB41.91kB43.22kB47.84kB48.28kB55.13kB61.28kB62.98kB63.11kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59 kB120.67kB128.71kB129.28 kB133.44kB164.28kB176.33kB180.40kB198.79kB218.14kB264.94 kВ298.57kB307.13kB343.99kB367.43kB689.63kB825.23 kB1,402.70 kB[plugin builtin:vite-reporter](!) Somechunks are larger than 500 kB after minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldown0ptions.output.codeSplitting to improve chunking: [URL_WITH_CREDENTIALS] ₴8APP...
|
NULL
|
-7952806826740830795
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelp>0 l4lSupport Daily - in 1h 24 m100% <47-zshAPP (-zsh)|X4-zshDOCKER881DEV (-zsh)APP (-zsh)X3./public/vue-assets/assets/ondemand-CE8XLH98.js:./public/vue-assets/assets/CrmLink-DKYsnHnx.js./public/vue-assets/assets/liquor-tree-COUefof4.js./public/vue-assets/assets/DealRiskList-3DSxnTNQ.js:./public/vue-assets/assets/AskAnything-BQ36E0GS.js:./public/vue-assets/assets/lib-CwM9toD2.js./public/vue-assets/assets/AppFormField-CqCXZwrk.js./public/vue-assets/assets/deal-view-B92qaj32.js:./public/vue-assets/assets/exports-D1lmea40.js../public/vue-assets/assets/playlists-u3UeUUz5.js../public/vue-assets/assets/callScoringTemplates-zeRn40ul.js../public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js•/public/vue-assets/assets/onboard-CCnYJmCP.js./public/vue-assets/assets/StatusBadge-CNGFZm8P.js./public/vue-assets/assets/kiosk-D6N5-ivP.js./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-jlkz65MZ.js../public/vue-assets/assets/ListView-DmKqYVi1.js:./public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js./public/vue-assets/assets/WelcomeLayout-B6wd32HG.js../public/vue-assets/assets/dashboard-BWoBIPKA.js:./public/vue-assets/assets/emoji-input-CSq87OVy.js../public/vue-assets/assets/AppButton-D3qMdODr.js../public/vue-assets/assets/sentry-icPKypZa.js:./public/vue-assets/assets/OrgSettingsLayout-BZ-JOgiH.js./public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js./public/vue-assets/assets/playback-DKsqp_al.js./public/vue-assets/assets/index.module-Bjlhgfd1.js./public/vue-assets/assets/intl-tel-input-BW4mv40Q.js:./public/vue-assets/assets/team-insights-D9P6ojjD.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js./public/vue-assets/assets/live-DWumv-QD.js../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-DiEs6xIb.js:./public/vue-assets/assets/logged-in-layout-Cq1ztH50.jsH8526.88kB27.91kB30.75kB34.39kB39.50kB39.69kB41.91kB43.22kB47.84kB48.28kB55.13kB61.28kB62.98kB63.11kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59 kB120.67kB128.71kB129.28 kB133.44kB164.28kB176.33kB180.40kB198.79kB218.14kB264.94 kВ298.57kB307.13kB343.99kB367.43kB689.63kB825.23 kB1,402.70 kB[plugin builtin:vite-reporter](!) Somechunks are larger than 500 kB after minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldown0ptions.output.codeSplitting to improve chunking: [URL_WITH_CREDENTIALS] ₴8APP...
|
46468
|
|
46471
|
NULL
|
0
|
2026-04-17T10:36:37.901077+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776422197901_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...
|
[{"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}]...
|
5574872765237312613
|
-7735694548458591292
|
click
|
hybrid
|
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
PhpStormFileFditViewNavigateCodelaravelRetactonRunToolsWindowHelpFV faVsco.s vT° JY-20692-fix-integration-app-[API_KEY] v>W testsU connect.lessV connect.vuedashboardW DeallnsightsD errorPages_ export-portalextension-installledinvitationvonconterenceWa VoUT_ LiveCoach_ Lockedlogin_ MeetingConsent_ mobilel onboardl•__mocks_•U_tests_V MobileAppDownk1 Onboard.lessVenhoard vilPC AutomatedReportsService.phpC) SendReportJob.phpC SendReportMailJob.phpC ReportController.phpTokenBuilder.phpc leamsetuocontroller.onppnp apl.ono© Filesystem.php© Team.php© CreateHeldActivityEvent.phpC) TrackProviderInstalledEvent.phpC RequestGenerateReportJob.phpOpportunitySyncTrait.phpC Opportunity.phpT InteractsWithPivotTable.phgOpoorunityupdated.oneOpportunitystageupdatea.pnpc) FventService?rovider.onmonoortunitvPendingAiAnalvsisatterstadechanded.omC RunOpportunityAiAnalysis.phpC ProcessAlAutomationAnalysiskesults.ong© ImportOpportunityBatch.phpTImportBatchJobTrait.php(C) Service.phpcachedStagesCc W.*trait upportunitysynctraitA33 V2 V1910181028private function resolveForecastCategory(?string $forecastCategory): stringf=1482 usages150private function importExternalFieldData(array $properties, int $opportunityid110501033$crmFields = $this->getOpportunitySyncableFields:Sthis->inportOpportunitvCrmFieldDataSproperties. ScrmFields.Sopportunitx154$155-1501034(aJ Outputini # 1695 X2 rowsvIX. AutovДQMAФW expires Y÷Irefresh token expires Yo_provider y1776336176<null> googleICIsImtpZC1617VmNWFhZGFkLTQwZDktNDLkNy04ZW12LTAzNmNLM.1729613615<null> zoom-phone= custom.loc= aravel.logc SF liminny@localhostC scratch 1.isonconnect.vue XV Onboard.vue< Hs local liminnyalocalnost4 console [EUlC CrmEntityRepository.pho(iii) crm configurations [EU]¿ console IPROD¿ consoe STAGINGII<script>101138methods:async integrationApp0nClickO{cons connecron = avalt unteoraronAodnteoramonuuns. locaurrovloer.naley.openNewConnectiongshowPoweredBy: false.allowMultipleConnections: falser.catch((err) => %console.LoglIntegrationApp openNewconnection rejected:', err):return null;7):all to Lascade, al to commandconsole.log('[IntegrationApp] openNewConnection resolved:'.wsun.scrinontyconnectlonoanex Relec File nae+ 2 of 2 files →loblSupport Daily • in 1h 24 mA100% [z7Fri 17 Apr 13:36:37AutomatedRenortsCommandTestCascadeAutomated Renort RetDebugging OpportuniUpdate Connection Loratner than resolving with a nul/disconnectea connection ob ect as it dla berore.To contirm. add a catch." isconst connection = awalt intedratlonaoointegration(this. Loca(Provider.name)onenNewconnectionshowPoweredBy: talse,allowMultipleConnections: false,}).catch((err) => 1console. loo untegrationApp openNewlonneccion relected: err;return nultLtlles with changesfront-end/src/components/connect/Jconnect.vue +2 -3tront-eno/src/componenis/onboarconooara.vue +l -2ASk anyinind dtl+ &CodeClaude Sonnet 4.6WServicesv _ DatabaseVdEUa console 1 s 59 msua crm_contigurations 1s 391 msd jiminny@localhost4SFd moocallV APROD< console ls o0o msV L STAGINGd console• Dockero_stateytul-rerreshfull-refreshIauth scope Yopenid https://www.googleap1s.com/auth/userinfo.prof1le https://www.googleap1s.com/auth/calendar http..phone:read:admin user:read:adminView allliReject allAccept allOJCSV.L+OgI retry after YIn crea12026-0.<null>2024-11Shortcuts conflicts: Clone Caret Above and 1 more shortcut conflict with macOS shortcuts. Modify these shortcuts or change macOS system settings. // Modify Shortcuts // Don't Show Again (today 12:22W Windsurf Teams 152:1 V UTF-8fh 2 spaces...
|
NULL
|
|
46616
|
NULL
|
0
|
2026-04-17T10:42:10.835654+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776422530835_m2.jpg...
|
iTerm2
|
APP (-zsh)
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
info Visit [URL_WITH_CREDENTIALS] ~/jiminny/app/fr info Visit [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yanrn build
zsh: command not found: yanrn
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn build
[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️ secrets for agents: [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn install
➤ YN0000: · Yarn 4.12.0
➤ YN0000: ┌ Resolution step
➤ YN0000: └ Completed
➤ YN0000: ┌ Post-resolution validation
➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide @testing-library/dom (pea6ced), requested by @testing-library/user-event.
➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide noty (p5bdb0d), requested by vue-components.
➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.
➤ YN0086: │ Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.
➤ YN0000: └ Completed
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed in 1s 655ms
➤ YN0000: ┌ Link step
➤ YN0008: │ root-workspace-0b6124@workspace:. must be rebuilt because its dependency tree changed
➤ YN0000: └ Completed in 0s 969ms
➤ YN0000: · Done with warnings in 3s 14ms
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn build
[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️ override existing env vars with { override: true }
vite v8.0.0 building client environment for production...
✓ 4656 modules transformed.
[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/
[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/
computing gzip size...
../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB
../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB
../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB
../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB
../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB
../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB
../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB
../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB
../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB
../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB
../public/vue-assets/assets/[EMAIL] 66.44 kB
../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB
../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB
../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB
../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB
../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB
../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB
../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB
../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB
../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB
../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB
../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB
../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB
../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB
../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB
../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB
../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB
../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB
../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB
../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB
../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB
../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB
../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB
../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB
../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB
../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB
../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB
../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB
../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB
../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB
../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB
../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB
../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB
../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB
../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB
../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB
../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB
../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB
../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB
../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB
../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB
../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB
../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB
../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB
../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB
../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB
../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB
../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB
../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB
../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB
../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB
../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB
../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB
../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB
../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB
../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB
../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB
../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB
../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB
../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB
../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB
../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB
../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB
../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB
../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB
../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB
../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB
../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB
../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB
../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB
../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB
../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB
../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB
../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB
../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB
../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB
../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB
../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB
../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB
../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB
../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB
../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB
../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB
../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB
../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB
../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB
../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB
../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB
../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB
../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB
../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB
../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB
../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB
../public/vue-assets/assets/theme-B5VdyMN_.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB
../public/vue-assets/assets/pick-C8Pw7kHx.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB
../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB
../public/vue-assets/assets/throttle-siTOKEMt.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB
../public/vue-assets/assets/lastFilters-B_B_O5LY.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB
../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gz...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ nvm use 24\nNow using node v24.11.1 (npm v11.6.2)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yanr build\nzsh: command not found: yanr\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yanrn build\nzsh: command not found: yanrn\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build \n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️\u0000 secrets for agents: https://dotenvx.com/as2\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.33 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-B6_hRKul.css 12.95 kB │ gzip: 2.92 kB\n../public/vue-assets/assets/tokens-CoAAv8do.css 13.41 kB │ gzip: 2.97 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-CrLnsUSQ.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-jGxSsmQW.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-DE_etUX9.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-CjyI5phg.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-DV-Svw8z.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-C6V4-Yml.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-C_yEm2e-.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-DyDQY_lm.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-DRJ7JhID.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-BrhP0P7D.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-DWZF-G8N.js 2.34 kB │ gzip: 1.23 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-CnVa_KZl.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-B2ThxTAd.js 2.59 kB │ gzip: 1.42 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-k3In5q1l.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-BZrDAfqV.js 3.45 kB │ gzip: 1.69 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-DJLlDZUr.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-DdBy-CTd.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-BYAvUsG9.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-EdnBkMBr.js 4.43 kB │ gzip: 2.04 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-B1SNfWGY.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/connect-DzHqvqqR.js 5.17 kB │ gzip: 2.28 kB │ map: 10.35 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-DbifQcp6.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-CYJkDTD8.js 6.25 kB │ gzip: 2.82 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-DkwzcZhV.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-CiBJ3oHz.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-BZWjw9pN.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-DLO6BXq1.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-prxYCVfk.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-B3i0pft-.js 15.17 kB │ gzip: 6.03 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-54ZutGpG.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-BpW-5w7g.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-Ber3EVyb.js 18.92 kB │ gzip: 6.75 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-BuNWx0BA.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-BMtylYi6.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-CP7J7-qo.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-kg1j3nIA.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-C8q7faXl.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-BgSY9SKi.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-COtjyq5c.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-bksUVN5E.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-Yn3gKP2z.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-BqxRx4zw.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-C2H2xj9g.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-BDlz4mpq.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BnVEi9yc.js 94.84 kB │ gzip: 28.16 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-CN2ZtlC7.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-XCNXJG27.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-DMzthP2u.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DYzOhe6q.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-DRpCBZO9.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-CPpPTAKV.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-Cfb59XkF.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-1Grk9P2e.js 825.23 kB │ gzip: 72.55 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-DArs1VTa.js 1,402.70 kB │ gzip: 438.08 kB │ map: 6,283.55 kB\n\n[PLUGIN_TIMINGS] Warning: Your build spent significant time in plugins. Here is a breakdown:\n - vite:css (61%)\n - vite-plugin-externals (12%)\n - vite:vue (9%)\n - sentry-vite-plugin (8%)\n - vite:worker (6%)\nSee https://rolldown.rs/options/checks#plugintimings for more details.\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 22.57s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn install\n➤ YN0000: · Yarn 4.12.0\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Post-resolution validation\n➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide @testing-library/dom (pea6ced), requested by @testing-library/user-event.\n➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide noty (p5bdb0d), requested by vue-components.\n➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.\n➤ YN0086: │ Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed in 1s 655ms\n➤ YN0000: ┌ Link step\n➤ YN0008: │ root-workspace-0b6124@workspace:. must be rebuilt because its dependency tree changed\n➤ YN0000: └ Completed in 0s 969ms\n➤ YN0000: · Done with warnings in 3s 14ms\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️\u0000 override existing env vars with { override: true }\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-B5VdyMN_.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-C8Pw7kHx.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-siTOKEMt.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-B_B_O5LY.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-CsQ1aO-4.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-7ycH7TWx.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-DUcV_uoi.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-CYQu1FG7.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-BjhdeObt.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-B2cExxmx.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings--S7_RYRE.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-cuK1068l.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-DxPLOs1S.js 2.59 kB │ gzip: 1.42 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-CVxaD0_B.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview--ydYatRe.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-CkLTUCy6.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-DPy-uEfS.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-D36CpnKB.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-C0YRJ_xR.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation--7sddGcP.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/connect-Bfq7LjjQ.js 5.17 kB │ gzip: 2.28 kB │ map: 10.35 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-Dj5DULYF.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-gY3HLgqP.js 6.25 kB │ gzip: 2.83 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-Bz_Gi9sn.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-Dgwsn6KA.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-DNS51mtN.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-CIGPrkbw.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-CPwNAOXA.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-DAEn44FQ.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-DB0CksQW.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-ClBvXCXq.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-BTQmbI4-.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-CqgLpVFE.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-4e6kQ9l_.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-CocIYUaz.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-CE8XLH98.js 26.88 kB │ gzip: 9.38 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-3DSxnTNQ.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-BQ36EoGS.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-CqCXZwrk.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-B92qaj32.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-u3UeUUz5.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-CCnYJmCP.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-CNGFZm8P.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-D6N5-ivP.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-jlkz65MZ.js 94.84 kB │ gzip: 28.16 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-DmKqYVi1.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-BWoBIPKA.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-icPKypZa.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-BZ-J0giH.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-DKsqp_aL.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-D9P6ojjD.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-DWumv-QD.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-DiEs6xIb.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-Cq1ztH5O.js 1,402.70 kB │ gzip: 438.08 kB │ map: 6,283.55 kB\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 13.59s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️\u0000 load multiple .env files with { path: ['.env.local', '.env'] }\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.33 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-CrWye7yo.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-D-kTQpNV.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-CEV1oVAM.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-D3xNTqAt.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-DftTc1_7.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-CK0K0ozg.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-DLwqdmK8.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-D8zd6tGl.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-D8AEMpQD.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-DZPkx2YQ.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-DHjh0S4O.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-CTc8r0pe.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-CHodD5F8.js 2.59 kB │ gzip: 1.43 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-CS9P7JeF.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-Dkxw0lkq.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-BdRuUFYY.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-Cploop1A.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-Cz-Y-wOJ.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-DZKBS9eW.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-DUWFWQvr.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/connect-LR_dB_VC.js 5.25 kB │ gzip: 2.30 kB │ map: 10.52 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-C6KkT8e1.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-D5dWNU9i.js 6.25 kB │ gzip: 2.82 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-DGWP8lD_.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-zafDvh_H.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-Co9u0t34.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-C820ZfzV.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-CfDB_Ohb.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-BhbjZb-1.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-BM3w9d6h.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-Kj7lCx0-.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-ulzm9gk2.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-C9INej-u.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-C-6FGhqy.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-mZhlfeUe.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-mq9zQOVK.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-BUgW-21G.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-CVCNDjsB.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-Cu4TbCsZ.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-CoZZkbGt.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-BpFWtNz1.js 48.28 kB │ gzip: 15.08 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-D27tiAEp.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-Dmn6sJ5_.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-DO4wa1Ld.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BKxR7Ag9.js 94.84 kB │ gzip: 28.17 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-V1cngeeb.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-D0nLYfJh.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-zRF85Jni.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DXmpAhyV.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-BerPTevk.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-Rfo09TvE.js 298.57 kB │ gzip: 77.21 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-BkQge7FT.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-RakUX3TF.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-WZXlxzkM.js 1,402.70 kB │ gzip: 438.07 kB │ map: 6,283.55 kB\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 25.43s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️\u0000 secrets for agents: https://dotenvx.com/as2\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-Cy-WIInU.js 0.63 kB │ gzip: 0.42 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-BGRRe-Qj.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-DmtOZ8h1.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-CzSBGyMt.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-BtwCuAYd.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-DG3xxJjJ.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-mV_4t574.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-g60shJd4.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-Z27Z5klt.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-Cn_qGwMd.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-BSSIkavG.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-C5_UlocO.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-Bad3U1k5.js 2.59 kB │ gzip: 1.43 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-D11UHzKP.js 2.97 kB │ gzip: 1.51 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-CoaCdIkx.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-C452yEnj.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-CloJVGCY.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-DJ4M3rJ1.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-B24mqvl1.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-B5F0aLwl.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/connect-DvCp38Q2.js 5.31 kB │ gzip: 2.31 kB │ map: 10.62 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-BvWVN0nz.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-CLdXMCwD.js 6.25 kB │ gzip: 2.83 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-PrWVOMsr.js 7.02 kB │ gzip: 3.17 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-BH9WFuby.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-DmPJHAz_.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-CQou30yT.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-BS8w3ovp.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-Cy_xmRg8.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-CZ2b-FlS.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-CdvjTszI.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-B1v5HdqS.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-uKCSfiLo.js 21.92 kB │ gzip: 8.55 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-zCtNIOGN.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-D5_z7YdA.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-mxFxJiHI.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-D7gUVql5.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-DgI0NfRA.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-BqQiLGFF.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-D4YlPwr_.js 43.22 kB │ gzip: 14.35 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-BWAdERcJ.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-CyAPGoFk.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-DNHiCr2i.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-dfcpodo5.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BVnPilVP.js 94.84 kB │ gzip: 28.17 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-DJD6SV4A.js 115.71 kB │ gzip: 33.79 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-CsDOiLAi.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-B3B1ZM6O.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DatDldIe.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-D1gm80qQ.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-DRugjYCA.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-CxSmZv7h.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-Cp0YOK4U.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-CE9ox17M.js 1,402.70 kB │ gzip: 438.07 kB │ map: 6,283.55 kB\n\n✓ built in 26.01s\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $","depth":4,"value":"info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ nvm use 24\nNow using node v24.11.1 (npm v11.6.2)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yanr build\nzsh: command not found: yanr\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yanrn build\nzsh: command not found: yanrn\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build \n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️\u0000 secrets for agents: https://dotenvx.com/as2\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.33 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-B6_hRKul.css 12.95 kB │ gzip: 2.92 kB\n../public/vue-assets/assets/tokens-CoAAv8do.css 13.41 kB │ gzip: 2.97 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-CrLnsUSQ.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-jGxSsmQW.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-DE_etUX9.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-CjyI5phg.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-DV-Svw8z.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-C6V4-Yml.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-C_yEm2e-.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-DyDQY_lm.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-DRJ7JhID.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-BrhP0P7D.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-DWZF-G8N.js 2.34 kB │ gzip: 1.23 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-CnVa_KZl.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-B2ThxTAd.js 2.59 kB │ gzip: 1.42 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-k3In5q1l.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-BZrDAfqV.js 3.45 kB │ gzip: 1.69 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-DJLlDZUr.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-DdBy-CTd.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-BYAvUsG9.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-EdnBkMBr.js 4.43 kB │ gzip: 2.04 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-B1SNfWGY.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/connect-DzHqvqqR.js 5.17 kB │ gzip: 2.28 kB │ map: 10.35 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-DbifQcp6.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-CYJkDTD8.js 6.25 kB │ gzip: 2.82 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-DkwzcZhV.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-CiBJ3oHz.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-BZWjw9pN.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-DLO6BXq1.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-prxYCVfk.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-B3i0pft-.js 15.17 kB │ gzip: 6.03 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-54ZutGpG.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-BpW-5w7g.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-Ber3EVyb.js 18.92 kB │ gzip: 6.75 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-BuNWx0BA.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-BMtylYi6.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-CP7J7-qo.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-kg1j3nIA.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-C8q7faXl.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-BgSY9SKi.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-COtjyq5c.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-bksUVN5E.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-Yn3gKP2z.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-BqxRx4zw.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-C2H2xj9g.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-BDlz4mpq.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BnVEi9yc.js 94.84 kB │ gzip: 28.16 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-CN2ZtlC7.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-XCNXJG27.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-DMzthP2u.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DYzOhe6q.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-DRpCBZO9.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-CPpPTAKV.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-Cfb59XkF.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-1Grk9P2e.js 825.23 kB │ gzip: 72.55 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-DArs1VTa.js 1,402.70 kB │ gzip: 438.08 kB │ map: 6,283.55 kB\n\n[PLUGIN_TIMINGS] Warning: Your build spent significant time in plugins. Here is a breakdown:\n - vite:css (61%)\n - vite-plugin-externals (12%)\n - vite:vue (9%)\n - sentry-vite-plugin (8%)\n - vite:worker (6%)\nSee https://rolldown.rs/options/checks#plugintimings for more details.\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 22.57s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn install\n➤ YN0000: · Yarn 4.12.0\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Post-resolution validation\n➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide @testing-library/dom (pea6ced), requested by @testing-library/user-event.\n➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide noty (p5bdb0d), requested by vue-components.\n➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.\n➤ YN0086: │ Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed in 1s 655ms\n➤ YN0000: ┌ Link step\n➤ YN0008: │ root-workspace-0b6124@workspace:. must be rebuilt because its dependency tree changed\n➤ YN0000: └ Completed in 0s 969ms\n➤ YN0000: · Done with warnings in 3s 14ms\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️\u0000 override existing env vars with { override: true }\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-B5VdyMN_.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-C8Pw7kHx.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-siTOKEMt.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-B_B_O5LY.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-CsQ1aO-4.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-7ycH7TWx.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-DUcV_uoi.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-CYQu1FG7.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-BjhdeObt.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-B2cExxmx.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings--S7_RYRE.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-cuK1068l.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-DxPLOs1S.js 2.59 kB │ gzip: 1.42 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-CVxaD0_B.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview--ydYatRe.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-CkLTUCy6.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-DPy-uEfS.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-D36CpnKB.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-C0YRJ_xR.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation--7sddGcP.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/connect-Bfq7LjjQ.js 5.17 kB │ gzip: 2.28 kB │ map: 10.35 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-Dj5DULYF.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-gY3HLgqP.js 6.25 kB │ gzip: 2.83 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-Bz_Gi9sn.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-Dgwsn6KA.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-DNS51mtN.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-CIGPrkbw.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-CPwNAOXA.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-DAEn44FQ.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-DB0CksQW.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-ClBvXCXq.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-BTQmbI4-.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-CqgLpVFE.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-4e6kQ9l_.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-CocIYUaz.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-CE8XLH98.js 26.88 kB │ gzip: 9.38 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-3DSxnTNQ.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-BQ36EoGS.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-CqCXZwrk.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-B92qaj32.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-u3UeUUz5.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-CCnYJmCP.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-CNGFZm8P.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-D6N5-ivP.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-jlkz65MZ.js 94.84 kB │ gzip: 28.16 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-DmKqYVi1.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-BWoBIPKA.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-icPKypZa.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-BZ-J0giH.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-DKsqp_aL.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-D9P6ojjD.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-DWumv-QD.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-DiEs6xIb.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-Cq1ztH5O.js 1,402.70 kB │ gzip: 438.08 kB │ map: 6,283.55 kB\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 13.59s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️\u0000 load multiple .env files with { path: ['.env.local', '.env'] }\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.33 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-CrWye7yo.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-D-kTQpNV.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-CEV1oVAM.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-D3xNTqAt.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-DftTc1_7.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-CK0K0ozg.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-DLwqdmK8.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-D8zd6tGl.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-D8AEMpQD.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-DZPkx2YQ.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-DHjh0S4O.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-CTc8r0pe.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-CHodD5F8.js 2.59 kB │ gzip: 1.43 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-CS9P7JeF.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-Dkxw0lkq.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-BdRuUFYY.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-Cploop1A.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-Cz-Y-wOJ.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-DZKBS9eW.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-DUWFWQvr.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/connect-LR_dB_VC.js 5.25 kB │ gzip: 2.30 kB │ map: 10.52 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-C6KkT8e1.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-D5dWNU9i.js 6.25 kB │ gzip: 2.82 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-DGWP8lD_.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-zafDvh_H.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-Co9u0t34.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-C820ZfzV.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-CfDB_Ohb.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-BhbjZb-1.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-BM3w9d6h.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-Kj7lCx0-.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-ulzm9gk2.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-C9INej-u.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-C-6FGhqy.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-mZhlfeUe.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-mq9zQOVK.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-BUgW-21G.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-CVCNDjsB.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-Cu4TbCsZ.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-CoZZkbGt.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-BpFWtNz1.js 48.28 kB │ gzip: 15.08 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-D27tiAEp.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-Dmn6sJ5_.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-DO4wa1Ld.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BKxR7Ag9.js 94.84 kB │ gzip: 28.17 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-V1cngeeb.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-D0nLYfJh.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-zRF85Jni.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DXmpAhyV.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-BerPTevk.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-Rfo09TvE.js 298.57 kB │ gzip: 77.21 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-BkQge7FT.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-RakUX3TF.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-WZXlxzkM.js 1,402.70 kB │ gzip: 438.07 kB │ map: 6,283.55 kB\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 25.43s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️\u0000 secrets for agents: https://dotenvx.com/as2\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-Cy-WIInU.js 0.63 kB │ gzip: 0.42 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-BGRRe-Qj.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-DmtOZ8h1.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-CzSBGyMt.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-BtwCuAYd.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-DG3xxJjJ.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-mV_4t574.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-g60shJd4.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-Z27Z5klt.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-Cn_qGwMd.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-BSSIkavG.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-C5_UlocO.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-Bad3U1k5.js 2.59 kB │ gzip: 1.43 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-D11UHzKP.js 2.97 kB │ gzip: 1.51 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-CoaCdIkx.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-C452yEnj.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-CloJVGCY.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-DJ4M3rJ1.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-B24mqvl1.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-B5F0aLwl.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/connect-DvCp38Q2.js 5.31 kB │ gzip: 2.31 kB │ map: 10.62 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-BvWVN0nz.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-CLdXMCwD.js 6.25 kB │ gzip: 2.83 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-PrWVOMsr.js 7.02 kB │ gzip: 3.17 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-BH9WFuby.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-DmPJHAz_.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-CQou30yT.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-BS8w3ovp.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-Cy_xmRg8.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-CZ2b-FlS.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-CdvjTszI.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-B1v5HdqS.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-uKCSfiLo.js 21.92 kB │ gzip: 8.55 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-zCtNIOGN.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-D5_z7YdA.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-mxFxJiHI.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-D7gUVql5.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-DgI0NfRA.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-BqQiLGFF.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-D4YlPwr_.js 43.22 kB │ gzip: 14.35 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-BWAdERcJ.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-CyAPGoFk.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-DNHiCr2i.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-dfcpodo5.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BVnPilVP.js 94.84 kB │ gzip: 28.17 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-DJD6SV4A.js 115.71 kB │ gzip: 33.79 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-CsDOiLAi.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-B3B1ZM6O.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DatDldIe.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-D1gm80qQ.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-DRugjYCA.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-CxSmZv7h.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-Cp0YOK4U.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-CE9ox17M.js 1,402.70 kB │ gzip: 438.07 kB │ map: 6,283.55 kB\n\n✓ built in 26.01s\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.23359375,"top":1.0,"width":0.06914063,"height":-0.037500024},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.2359375,"top":1.0,"width":0.00625,"height":-0.04027772},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.30273438,"top":1.0,"width":0.06914063,"height":-0.037500024},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.30507812,"top":1.0,"width":0.00625,"height":-0.04027772},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (-zsh)","depth":2,"bounds":{"left":0.371875,"top":1.0,"width":0.06914063,"height":-0.037500024},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.37421876,"top":1.0,"width":0.00625,"height":-0.04027772},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.44101563,"top":1.0,"width":0.06914063,"height":-0.037500024},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.44335938,"top":1.0,"width":0.00625,"height":-0.04027772},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.5101563,"top":1.0,"width":0.06914063,"height":-0.037500024},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.5125,"top":1.0,"width":0.00625,"height":-0.04027772},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"✳ Review screenpipe usage and Boosteroid integration (claude)","depth":2,"bounds":{"left":0.5792969,"top":1.0,"width":0.06914063,"height":-0.037500024},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.5816406,"top":1.0,"width":0.00625,"height":-0.04027772},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ec2-user@ip-10-30-159-186:~ (nc)","depth":2,"bounds":{"left":0.6484375,"top":1.0,"width":0.06914063,"height":-0.037500024},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.6507813,"top":1.0,"width":0.00625,"height":-0.04027772},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ec2-user@ip-10-20-6-111:~ (nc)","depth":2,"bounds":{"left":0.7175781,"top":1.0,"width":0.06914063,"height":-0.037500024},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.7199219,"top":1.0,"width":0.00625,"height":-0.04027772},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.77070314,"top":1.0,"width":0.021875,"height":-0.020833373},"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (-zsh)","depth":1,"bounds":{"left":0.50039065,"top":1.0,"width":0.02890625,"height":-0.021527767},"role_description":"text"}]...
|
-2407191479157132203
|
3429803486563842285
|
idle
|
accessibility
|
NULL
|
info Visit [URL_WITH_CREDENTIALS] ~/jiminny/app/fr info Visit [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yanrn build
zsh: command not found: yanrn
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn build
[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️ secrets for agents: [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn install
➤ YN0000: · Yarn 4.12.0
➤ YN0000: ┌ Resolution step
➤ YN0000: └ Completed
➤ YN0000: ┌ Post-resolution validation
➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide @testing-library/dom (pea6ced), requested by @testing-library/user-event.
➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide noty (p5bdb0d), requested by vue-components.
➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.
➤ YN0086: │ Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.
➤ YN0000: └ Completed
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed in 1s 655ms
➤ YN0000: ┌ Link step
➤ YN0008: │ root-workspace-0b6124@workspace:. must be rebuilt because its dependency tree changed
➤ YN0000: └ Completed in 0s 969ms
➤ YN0000: · Done with warnings in 3s 14ms
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn build
[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️ override existing env vars with { override: true }
vite v8.0.0 building client environment for production...
✓ 4656 modules transformed.
[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/
[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/
computing gzip size...
../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB
../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB
../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB
../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB
../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB
../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB
../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB
../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB
../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB
../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB
../public/vue-assets/assets/[EMAIL] 66.44 kB
../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB
../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB
../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB
../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB
../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB
../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB
../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB
../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB
../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB
../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB
../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB
../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB
../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB
../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB
../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB
../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB
../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB
../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB
../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB
../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB
../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB
../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB
../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB
../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB
../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB
../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB
../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB
../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB
../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB
../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB
../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB
../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB
../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB
../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB
../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB
../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB
../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB
../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB
../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB
../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB
../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB
../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB
../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB
../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB
../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB
../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB
../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB
../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB
../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB
../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB
../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB
../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB
../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB
../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB
../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB
../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB
../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB
../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB
../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB
../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB
../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB
../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB
../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB
../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB
../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB
../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB
../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB
../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB
../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB
../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB
../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB
../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB
../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB
../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB
../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB
../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB
../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB
../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB
../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB
../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB
../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB
../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB
../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB
../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB
../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB
../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB
../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB
../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB
../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB
../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB
../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB
../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB
../public/vue-assets/assets/theme-B5VdyMN_.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB
../public/vue-assets/assets/pick-C8Pw7kHx.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB
../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB
../public/vue-assets/assets/throttle-siTOKEMt.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB
../public/vue-assets/assets/lastFilters-B_B_O5LY.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB
../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gz...
|
NULL
|
|
46617
|
NULL
|
0
|
2026-04-17T10:42:18.997032+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776422538997_m1.jpg...
|
iTerm2
|
APP (-zsh)
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
info Visit [URL_WITH_CREDENTIALS] ~/jiminny/app/fr info Visit [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yanrn build
zsh: command not found: yanrn
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn build
[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️ secrets for agents: [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn install
➤ YN0000: · Yarn 4.12.0
➤ YN0000: ┌ Resolution step
➤ YN0000: └ Completed
➤ YN0000: ┌ Post-resolution validation
➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide @testing-library/dom (pea6ced), requested by @testing-library/user-event.
➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide noty (p5bdb0d), requested by vue-components.
➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.
➤ YN0086: │ Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.
➤ YN0000: └ Completed
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed in 1s 655ms
➤ YN0000: ┌ Link step
➤ YN0008: │ root-workspace-0b6124@workspace:. must be rebuilt because its dependency tree changed
➤ YN0000: └ Completed in 0s 969ms
➤ YN0000: · Done with warnings in 3s 14ms
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn build
[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️ override existing env vars with { override: true }
vite v8.0.0 building client environment for production...
✓ 4656 modules transformed.
[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/
[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/
computing gzip size...
../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB
../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB
../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB
../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB
../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB
../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB
../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB
../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB
../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB
../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB
../public/vue-assets/assets/[EMAIL] 66.44 kB
../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB
../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB
../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB
../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB
../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB
../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB
../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB
../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB
../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB
../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB
../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB
../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB
../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB
../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB
../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB
../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB
../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB
../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB
../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB
../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB
../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB
../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB
../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB
../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB
../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB
../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB
../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB
../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB
../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB
../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB
../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB
../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB
../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB
../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB
../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB
../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB
../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB
../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB
../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB
../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB
../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB
../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB
../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB
../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB
../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB
../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB
../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB
../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB
../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB
../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB
../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB
../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB
../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB
../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB
../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB
../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB
../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB
../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB
../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB
../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB
../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB
../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB
../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB
../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB
../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB
../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB
../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB
../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB
../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB
../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB
../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB
../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB
../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB
../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB
../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB
../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB
../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB
../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB
../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB
../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB
../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB
../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB
../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB
../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB
../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB
../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB
../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB
../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB
../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB
../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB
../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB
../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB
../public/vue-assets/assets/theme-B5VdyMN_.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB
../public/vue-assets/assets/pick-C8Pw7kHx.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB
../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB
../public/vue-assets/assets/throttle-siTOKEMt.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB
../public/vue-assets/assets/lastFilters-B_B_O5LY.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB
../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gz...
|
[{"role":"AXTextArea","text [{"role":"AXTextArea","text":"info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ nvm use 24\nNow using node v24.11.1 (npm v11.6.2)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yanr build\nzsh: command not found: yanr\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yanrn build\nzsh: command not found: yanrn\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build \n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️\u0000 secrets for agents: https://dotenvx.com/as2\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.33 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-B6_hRKul.css 12.95 kB │ gzip: 2.92 kB\n../public/vue-assets/assets/tokens-CoAAv8do.css 13.41 kB │ gzip: 2.97 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-CrLnsUSQ.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-jGxSsmQW.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-DE_etUX9.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-CjyI5phg.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-DV-Svw8z.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-C6V4-Yml.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-C_yEm2e-.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-DyDQY_lm.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-DRJ7JhID.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-BrhP0P7D.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-DWZF-G8N.js 2.34 kB │ gzip: 1.23 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-CnVa_KZl.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-B2ThxTAd.js 2.59 kB │ gzip: 1.42 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-k3In5q1l.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-BZrDAfqV.js 3.45 kB │ gzip: 1.69 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-DJLlDZUr.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-DdBy-CTd.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-BYAvUsG9.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-EdnBkMBr.js 4.43 kB │ gzip: 2.04 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-B1SNfWGY.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/connect-DzHqvqqR.js 5.17 kB │ gzip: 2.28 kB │ map: 10.35 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-DbifQcp6.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-CYJkDTD8.js 6.25 kB │ gzip: 2.82 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-DkwzcZhV.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-CiBJ3oHz.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-BZWjw9pN.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-DLO6BXq1.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-prxYCVfk.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-B3i0pft-.js 15.17 kB │ gzip: 6.03 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-54ZutGpG.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-BpW-5w7g.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-Ber3EVyb.js 18.92 kB │ gzip: 6.75 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-BuNWx0BA.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-BMtylYi6.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-CP7J7-qo.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-kg1j3nIA.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-C8q7faXl.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-BgSY9SKi.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-COtjyq5c.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-bksUVN5E.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-Yn3gKP2z.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-BqxRx4zw.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-C2H2xj9g.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-BDlz4mpq.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BnVEi9yc.js 94.84 kB │ gzip: 28.16 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-CN2ZtlC7.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-XCNXJG27.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-DMzthP2u.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DYzOhe6q.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-DRpCBZO9.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-CPpPTAKV.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-Cfb59XkF.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-1Grk9P2e.js 825.23 kB │ gzip: 72.55 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-DArs1VTa.js 1,402.70 kB │ gzip: 438.08 kB │ map: 6,283.55 kB\n\n[PLUGIN_TIMINGS] Warning: Your build spent significant time in plugins. Here is a breakdown:\n - vite:css (61%)\n - vite-plugin-externals (12%)\n - vite:vue (9%)\n - sentry-vite-plugin (8%)\n - vite:worker (6%)\nSee https://rolldown.rs/options/checks#plugintimings for more details.\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 22.57s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn install\n➤ YN0000: · Yarn 4.12.0\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Post-resolution validation\n➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide @testing-library/dom (pea6ced), requested by @testing-library/user-event.\n➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide noty (p5bdb0d), requested by vue-components.\n➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.\n➤ YN0086: │ Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed in 1s 655ms\n➤ YN0000: ┌ Link step\n➤ YN0008: │ root-workspace-0b6124@workspace:. must be rebuilt because its dependency tree changed\n➤ YN0000: └ Completed in 0s 969ms\n➤ YN0000: · Done with warnings in 3s 14ms\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️\u0000 override existing env vars with { override: true }\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-B5VdyMN_.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-C8Pw7kHx.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-siTOKEMt.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-B_B_O5LY.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-CsQ1aO-4.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-7ycH7TWx.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-DUcV_uoi.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-CYQu1FG7.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-BjhdeObt.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-B2cExxmx.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings--S7_RYRE.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-cuK1068l.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-DxPLOs1S.js 2.59 kB │ gzip: 1.42 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-CVxaD0_B.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview--ydYatRe.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-CkLTUCy6.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-DPy-uEfS.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-D36CpnKB.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-C0YRJ_xR.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation--7sddGcP.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/connect-Bfq7LjjQ.js 5.17 kB │ gzip: 2.28 kB │ map: 10.35 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-Dj5DULYF.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-gY3HLgqP.js 6.25 kB │ gzip: 2.83 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-Bz_Gi9sn.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-Dgwsn6KA.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-DNS51mtN.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-CIGPrkbw.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-CPwNAOXA.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-DAEn44FQ.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-DB0CksQW.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-ClBvXCXq.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-BTQmbI4-.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-CqgLpVFE.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-4e6kQ9l_.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-CocIYUaz.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-CE8XLH98.js 26.88 kB │ gzip: 9.38 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-3DSxnTNQ.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-BQ36EoGS.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-CqCXZwrk.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-B92qaj32.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-u3UeUUz5.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-CCnYJmCP.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-CNGFZm8P.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-D6N5-ivP.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-jlkz65MZ.js 94.84 kB │ gzip: 28.16 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-DmKqYVi1.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-BWoBIPKA.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-icPKypZa.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-BZ-J0giH.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-DKsqp_aL.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-D9P6ojjD.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-DWumv-QD.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-DiEs6xIb.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-Cq1ztH5O.js 1,402.70 kB │ gzip: 438.08 kB │ map: 6,283.55 kB\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 13.59s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️\u0000 load multiple .env files with { path: ['.env.local', '.env'] }\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.33 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-CrWye7yo.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-D-kTQpNV.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-CEV1oVAM.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-D3xNTqAt.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-DftTc1_7.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-CK0K0ozg.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-DLwqdmK8.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-D8zd6tGl.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-D8AEMpQD.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-DZPkx2YQ.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-DHjh0S4O.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-CTc8r0pe.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-CHodD5F8.js 2.59 kB │ gzip: 1.43 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-CS9P7JeF.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-Dkxw0lkq.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-BdRuUFYY.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-Cploop1A.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-Cz-Y-wOJ.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-DZKBS9eW.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-DUWFWQvr.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/connect-LR_dB_VC.js 5.25 kB │ gzip: 2.30 kB │ map: 10.52 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-C6KkT8e1.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-D5dWNU9i.js 6.25 kB │ gzip: 2.82 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-DGWP8lD_.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-zafDvh_H.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-Co9u0t34.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-C820ZfzV.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-CfDB_Ohb.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-BhbjZb-1.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-BM3w9d6h.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-Kj7lCx0-.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-ulzm9gk2.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-C9INej-u.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-C-6FGhqy.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-mZhlfeUe.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-mq9zQOVK.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-BUgW-21G.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-CVCNDjsB.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-Cu4TbCsZ.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-CoZZkbGt.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-BpFWtNz1.js 48.28 kB │ gzip: 15.08 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-D27tiAEp.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-Dmn6sJ5_.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-DO4wa1Ld.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BKxR7Ag9.js 94.84 kB │ gzip: 28.17 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-V1cngeeb.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-D0nLYfJh.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-zRF85Jni.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DXmpAhyV.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-BerPTevk.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-Rfo09TvE.js 298.57 kB │ gzip: 77.21 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-BkQge7FT.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-RakUX3TF.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-WZXlxzkM.js 1,402.70 kB │ gzip: 438.07 kB │ map: 6,283.55 kB\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 25.43s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️\u0000 secrets for agents: https://dotenvx.com/as2\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-Cy-WIInU.js 0.63 kB │ gzip: 0.42 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-BGRRe-Qj.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-DmtOZ8h1.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-CzSBGyMt.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-BtwCuAYd.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-DG3xxJjJ.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-mV_4t574.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-g60shJd4.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-Z27Z5klt.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-Cn_qGwMd.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-BSSIkavG.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-C5_UlocO.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-Bad3U1k5.js 2.59 kB │ gzip: 1.43 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-D11UHzKP.js 2.97 kB │ gzip: 1.51 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-CoaCdIkx.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-C452yEnj.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-CloJVGCY.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-DJ4M3rJ1.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-B24mqvl1.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-B5F0aLwl.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/connect-DvCp38Q2.js 5.31 kB │ gzip: 2.31 kB │ map: 10.62 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-BvWVN0nz.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-CLdXMCwD.js 6.25 kB │ gzip: 2.83 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-PrWVOMsr.js 7.02 kB │ gzip: 3.17 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-BH9WFuby.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-DmPJHAz_.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-CQou30yT.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-BS8w3ovp.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-Cy_xmRg8.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-CZ2b-FlS.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-CdvjTszI.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-B1v5HdqS.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-uKCSfiLo.js 21.92 kB │ gzip: 8.55 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-zCtNIOGN.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-D5_z7YdA.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-mxFxJiHI.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-D7gUVql5.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-DgI0NfRA.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-BqQiLGFF.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-D4YlPwr_.js 43.22 kB │ gzip: 14.35 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-BWAdERcJ.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-CyAPGoFk.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-DNHiCr2i.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-dfcpodo5.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BVnPilVP.js 94.84 kB │ gzip: 28.17 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-DJD6SV4A.js 115.71 kB │ gzip: 33.79 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-CsDOiLAi.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-B3B1ZM6O.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DatDldIe.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-D1gm80qQ.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-DRugjYCA.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-CxSmZv7h.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-Cp0YOK4U.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-CE9ox17M.js 1,402.70 kB │ gzip: 438.07 kB │ map: 6,283.55 kB\n\n✓ built in 26.01s\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $","depth":4,"value":"info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ nvm use 24\nNow using node v24.11.1 (npm v11.6.2)\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yanr build\nzsh: command not found: yanr\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yanrn build\nzsh: command not found: yanrn\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build \n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️\u0000 secrets for agents: https://dotenvx.com/as2\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.33 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-B6_hRKul.css 12.95 kB │ gzip: 2.92 kB\n../public/vue-assets/assets/tokens-CoAAv8do.css 13.41 kB │ gzip: 2.97 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-CrLnsUSQ.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-jGxSsmQW.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-DE_etUX9.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-CjyI5phg.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-DV-Svw8z.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-C6V4-Yml.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-C_yEm2e-.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-DyDQY_lm.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-DRJ7JhID.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-BrhP0P7D.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-DWZF-G8N.js 2.34 kB │ gzip: 1.23 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-CnVa_KZl.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-B2ThxTAd.js 2.59 kB │ gzip: 1.42 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-k3In5q1l.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-BZrDAfqV.js 3.45 kB │ gzip: 1.69 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-DJLlDZUr.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-DdBy-CTd.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-BYAvUsG9.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-EdnBkMBr.js 4.43 kB │ gzip: 2.04 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-B1SNfWGY.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/connect-DzHqvqqR.js 5.17 kB │ gzip: 2.28 kB │ map: 10.35 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-DbifQcp6.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-CYJkDTD8.js 6.25 kB │ gzip: 2.82 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-DkwzcZhV.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-CiBJ3oHz.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-BZWjw9pN.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-DLO6BXq1.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-prxYCVfk.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-B3i0pft-.js 15.17 kB │ gzip: 6.03 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-54ZutGpG.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-BpW-5w7g.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-Ber3EVyb.js 18.92 kB │ gzip: 6.75 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-BuNWx0BA.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-BMtylYi6.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-CP7J7-qo.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-kg1j3nIA.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-C8q7faXl.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-BgSY9SKi.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-COtjyq5c.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-bksUVN5E.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-Yn3gKP2z.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-BqxRx4zw.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-C2H2xj9g.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-BDlz4mpq.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BnVEi9yc.js 94.84 kB │ gzip: 28.16 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-CN2ZtlC7.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-XCNXJG27.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-DMzthP2u.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DYzOhe6q.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-DRpCBZO9.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-CPpPTAKV.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-Cfb59XkF.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-1Grk9P2e.js 825.23 kB │ gzip: 72.55 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-DArs1VTa.js 1,402.70 kB │ gzip: 438.08 kB │ map: 6,283.55 kB\n\n[PLUGIN_TIMINGS] Warning: Your build spent significant time in plugins. Here is a breakdown:\n - vite:css (61%)\n - vite-plugin-externals (12%)\n - vite:vue (9%)\n - sentry-vite-plugin (8%)\n - vite:worker (6%)\nSee https://rolldown.rs/options/checks#plugintimings for more details.\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 22.57s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn install\n➤ YN0000: · Yarn 4.12.0\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Post-resolution validation\n➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide @testing-library/dom (pea6ced), requested by @testing-library/user-event.\n➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide noty (p5bdb0d), requested by vue-components.\n➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.\n➤ YN0086: │ Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed in 1s 655ms\n➤ YN0000: ┌ Link step\n➤ YN0008: │ root-workspace-0b6124@workspace:. must be rebuilt because its dependency tree changed\n➤ YN0000: └ Completed in 0s 969ms\n➤ YN0000: · Done with warnings in 3s 14ms\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️\u0000 override existing env vars with { override: true }\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-B5VdyMN_.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-C8Pw7kHx.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-siTOKEMt.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-B_B_O5LY.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-CsQ1aO-4.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-7ycH7TWx.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-DUcV_uoi.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-CYQu1FG7.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-BjhdeObt.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-B2cExxmx.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings--S7_RYRE.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-cuK1068l.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-DxPLOs1S.js 2.59 kB │ gzip: 1.42 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-CVxaD0_B.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview--ydYatRe.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-CkLTUCy6.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-DPy-uEfS.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-D36CpnKB.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-C0YRJ_xR.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation--7sddGcP.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/connect-Bfq7LjjQ.js 5.17 kB │ gzip: 2.28 kB │ map: 10.35 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-Dj5DULYF.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-gY3HLgqP.js 6.25 kB │ gzip: 2.83 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-Bz_Gi9sn.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-Dgwsn6KA.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-DNS51mtN.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-CIGPrkbw.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-CPwNAOXA.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-DAEn44FQ.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-DB0CksQW.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-ClBvXCXq.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-BTQmbI4-.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-CqgLpVFE.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-4e6kQ9l_.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-CocIYUaz.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-CE8XLH98.js 26.88 kB │ gzip: 9.38 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-3DSxnTNQ.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-BQ36EoGS.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-CqCXZwrk.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-B92qaj32.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-u3UeUUz5.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-CCnYJmCP.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-CNGFZm8P.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-D6N5-ivP.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-jlkz65MZ.js 94.84 kB │ gzip: 28.16 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-DmKqYVi1.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-BWoBIPKA.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-icPKypZa.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-BZ-J0giH.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-DKsqp_aL.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-D9P6ojjD.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-DWumv-QD.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-DiEs6xIb.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-Cq1ztH5O.js 1,402.70 kB │ gzip: 438.08 kB │ map: 6,283.55 kB\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 13.59s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️\u0000 load multiple .env files with { path: ['.env.local', '.env'] }\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.33 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-CrWye7yo.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-D-kTQpNV.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-CEV1oVAM.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-D3xNTqAt.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-DftTc1_7.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-CK0K0ozg.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-DLwqdmK8.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-D8zd6tGl.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-D8AEMpQD.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-DZPkx2YQ.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-DHjh0S4O.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-CTc8r0pe.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-CHodD5F8.js 2.59 kB │ gzip: 1.43 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-CS9P7JeF.js 2.97 kB │ gzip: 1.50 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-Dkxw0lkq.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-BdRuUFYY.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-Cploop1A.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-Cz-Y-wOJ.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-DZKBS9eW.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-DUWFWQvr.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/connect-LR_dB_VC.js 5.25 kB │ gzip: 2.30 kB │ map: 10.52 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-C6KkT8e1.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-D5dWNU9i.js 6.25 kB │ gzip: 2.82 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-DGWP8lD_.js 7.02 kB │ gzip: 3.16 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-zafDvh_H.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-Co9u0t34.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-C820ZfzV.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-CfDB_Ohb.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-BhbjZb-1.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-BM3w9d6h.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-Kj7lCx0-.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-ulzm9gk2.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-C9INej-u.js 21.92 kB │ gzip: 8.54 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-C-6FGhqy.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-mZhlfeUe.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-mq9zQOVK.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-BUgW-21G.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-CVCNDjsB.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-Cu4TbCsZ.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-CoZZkbGt.js 43.22 kB │ gzip: 14.34 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-BpFWtNz1.js 48.28 kB │ gzip: 15.08 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-D27tiAEp.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-Dmn6sJ5_.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-DO4wa1Ld.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BKxR7Ag9.js 94.84 kB │ gzip: 28.17 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-V1cngeeb.js 115.71 kB │ gzip: 33.78 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-D0nLYfJh.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-zRF85Jni.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DXmpAhyV.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-BerPTevk.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-Rfo09TvE.js 298.57 kB │ gzip: 77.21 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-BkQge7FT.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-RakUX3TF.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-WZXlxzkM.js 1,402.70 kB │ gzip: 438.07 kB │ map: 6,283.55 kB\n\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\n✓ built in 25.43s\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $ yarn build\n[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️\u0000 secrets for agents: https://dotenvx.com/as2\nvite v8.0.0 building client environment for production...\n✓ 4656 modules transformed.\n[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\n[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/\ncomputing gzip size...\n../public/vue-assets/index.html 4.58 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB\n../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB\n../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB\n../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB\n../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB\n../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB\n../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB\n../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB\n../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB\n../public/vue-assets/assets/flags@2x-gR6KPp3x.webp 66.44 kB\n../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB\n../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB\n../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB\n../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB\n../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB\n../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB\n../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB\n../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB\n../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB\n../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB\n../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB\n../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB\n../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB\n../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB\n../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB\n../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB\n../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB\n../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB\n../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB\n../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB\n../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB\n../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB\n../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB\n../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB\n../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB\n../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB\n../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB\n../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB\n../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB\n../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB\n../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB\n../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB\n../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB\n../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB\n../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB\n../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB\n../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB\n../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB\n../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB\n../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB\n../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB\n../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB\n../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB\n../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB\n../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB\n../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB\n../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB\n../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB\n../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB\n../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB\n../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB\n../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB\n../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB\n../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB\n../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB\n../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB\n../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB\n../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB\n../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB\n../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB\n../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB\n../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB\n../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB\n../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB\n../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB\n../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB\n../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB\n../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB\n../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB\n../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB\n../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB\n../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB\n../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB\n../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB\n../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB\n../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB\n../public/vue-assets/assets/theme-Cy-WIInU.js 0.63 kB │ gzip: 0.42 kB │ map: 0.49 kB\n../public/vue-assets/assets/pick-BGRRe-Qj.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB\n../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB\n../public/vue-assets/assets/throttle-DmtOZ8h1.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB\n../public/vue-assets/assets/lastFilters-CzSBGyMt.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB\n../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gzip: 0.50 kB │ map: 1.64 kB\n../public/vue-assets/assets/v-focus-618yf9WO.js 0.85 kB │ gzip: 0.52 kB │ map: 1.42 kB\n../public/vue-assets/assets/ListLoader-bfQmkVGu.js 0.87 kB │ gzip: 0.54 kB │ map: 2.90 kB\n../public/vue-assets/assets/utils-CAMifZzY.js 0.88 kB │ gzip: 0.53 kB │ map: 2.45 kB\n../public/vue-assets/assets/mobileApp-QFLS3kId.js 0.89 kB │ gzip: 0.54 kB │ map: 1.12 kB\n../public/vue-assets/assets/pickBy-BtwCuAYd.js 0.95 kB │ gzip: 0.59 kB │ map: 2.57 kB\n../public/vue-assets/assets/BuildInfo-CIGre86L.js 1.00 kB │ gzip: 0.65 kB │ map: 1.48 kB\n../public/vue-assets/assets/LogoShort100-D_SQdBYB.js 1.01 kB │ gzip: 0.64 kB │ map: 1.23 kB\n../public/vue-assets/assets/useDrawerModal-CmhXI-pZ.js 1.04 kB │ gzip: 0.65 kB │ map: 1.94 kB\n../public/vue-assets/assets/_getAllKeysIn-CkMQAnJa.js 1.12 kB │ gzip: 0.67 kB │ map: 4.83 kB\n../public/vue-assets/assets/useAutosizeTextarea-DyMwh_20.js 1.13 kB │ gzip: 0.70 kB │ map: 3.16 kB\n../public/vue-assets/assets/planhat-DPA36Hcn.js 1.21 kB │ gzip: 0.71 kB │ map: 2.18 kB\n../public/vue-assets/assets/useStoreModule-Cx6UoGVJ.js 1.35 kB │ gzip: 0.72 kB │ map: 6.03 kB\n../public/vue-assets/assets/GenericMessage-BxGsrfvw.js 1.39 kB │ gzip: 0.75 kB │ map: 1.90 kB\n../public/vue-assets/assets/BrowserExtensionInstaller-DimyBPIi.js 1.45 kB │ gzip: 0.78 kB │ map: 2.05 kB\n../public/vue-assets/assets/debounce-DG3xxJjJ.js 1.53 kB │ gzip: 0.83 kB │ map: 8.61 kB\n../public/vue-assets/assets/extension-installed-mV_4t574.js 1.60 kB │ gzip: 0.91 kB │ map: 3.20 kB\n../public/vue-assets/assets/RecipientsCell-BS9gDGey.js 1.61 kB │ gzip: 0.93 kB │ map: 4.04 kB\n../public/vue-assets/assets/PrimaryButton-DaKR3UAG.js 1.61 kB │ gzip: 0.87 kB │ map: 3.73 kB\n../public/vue-assets/assets/KioskBanner-g60shJd4.js 1.66 kB │ gzip: 1.00 kB │ map: 2.88 kB\n../public/vue-assets/assets/LogoLong100-Hid0kvjR.js 1.78 kB │ gzip: 1.08 kB │ map: 5.91 kB\n../public/vue-assets/assets/GoogleLikeButton-CGFy3nbz.js 1.80 kB │ gzip: 0.84 kB │ map: 4.71 kB\n../public/vue-assets/assets/AppAlert-DFp0nftl.js 1.86 kB │ gzip: 1.01 kB │ map: 6.51 kB\n../public/vue-assets/assets/usePusherEventListener-Z27Z5klt.js 1.93 kB │ gzip: 1.08 kB │ map: 9.59 kB\n../public/vue-assets/assets/AppForm-BgtH5xsM.js 2.01 kB │ gzip: 1.10 kB │ map: 6.89 kB\n../public/vue-assets/assets/vee-validate-rules-IFeGZHb3.js 2.02 kB │ gzip: 0.89 kB │ map: 22.31 kB\n../public/vue-assets/assets/literals-CXSwYC8y.js 2.05 kB │ gzip: 0.97 kB │ map: 3.08 kB\n../public/vue-assets/assets/TabEmptyState-pk8vRxJt.js 2.16 kB │ gzip: 1.12 kB │ map: 8.26 kB\n../public/vue-assets/assets/Replies-Cn_qGwMd.js 2.23 kB │ gzip: 1.14 kB │ map: 2.40 kB\n../public/vue-assets/assets/settings-BSSIkavG.js 2.34 kB │ gzip: 1.24 kB │ map: 1.73 kB\n../public/vue-assets/assets/SeekBtn-CMp8sSUA.js 2.47 kB │ gzip: 1.18 kB │ map: 9.27 kB\n../public/vue-assets/assets/AiAutomation-C5_UlocO.js 2.59 kB │ gzip: 1.37 kB │ map: 4.45 kB\n../public/vue-assets/assets/locked-Bad3U1k5.js 2.59 kB │ gzip: 1.43 kB │ map: 4.00 kB\n../public/vue-assets/assets/DrawerWidget-BPC-tCgo.js 2.68 kB │ gzip: 1.33 kB │ map: 6.95 kB\n../public/vue-assets/assets/vue-infinite-scroll-D0cI4gH6.js 2.72 kB │ gzip: 1.28 kB │ map: 9.99 kB\n../public/vue-assets/assets/useActivityCustomerName-CYuaZj-p.js 2.87 kB │ gzip: 1.41 kB │ map: 6.27 kB\n../public/vue-assets/assets/other-D11UHzKP.js 2.97 kB │ gzip: 1.51 kB │ map: 3.38 kB\n../public/vue-assets/assets/InputDropdown-CBM8xiPT.js 3.33 kB │ gzip: 1.61 kB │ map: 6.02 kB\n../public/vue-assets/assets/AvatarsStack-hxex9Ic8.js 3.40 kB │ gzip: 1.51 kB │ map: 9.54 kB\n../public/vue-assets/assets/activity-preview-CoaCdIkx.js 3.45 kB │ gzip: 1.68 kB │ map: 10.11 kB\n../public/vue-assets/assets/FollowModal-SMYvVwqG.js 3.70 kB │ gzip: 1.81 kB │ map: 8.86 kB\n../public/vue-assets/assets/softphone-coach-C452yEnj.js 3.74 kB │ gzip: 1.96 kB │ map: 5.03 kB\n../public/vue-assets/assets/store-CloJVGCY.js 4.10 kB │ gzip: 2.00 kB │ map: 18.73 kB\n../public/vue-assets/assets/AiContext-DJ4M3rJ1.js 4.34 kB │ gzip: 2.08 kB │ map: 13.11 kB\n../public/vue-assets/assets/meeting-consent-B24mqvl1.js 4.43 kB │ gzip: 2.05 kB │ map: 9.72 kB\n../public/vue-assets/assets/vue-multiselect-VB2Agtp1.js 4.49 kB │ gzip: 1.93 kB │ map: 14.85 kB\n../public/vue-assets/assets/snackbarNotifications-DjDIGkWd.js 4.83 kB │ gzip: 1.58 kB │ map: 11.62 kB\n../public/vue-assets/assets/invitation-B5F0aLwl.js 4.88 kB │ gzip: 2.21 kB │ map: 19.38 kB\n../public/vue-assets/assets/textarea-caret-B2HdIdpZ.js 5.25 kB │ gzip: 2.41 kB │ map: 14.48 kB\n../public/vue-assets/assets/connect-DvCp38Q2.js 5.31 kB │ gzip: 2.31 kB │ map: 10.62 kB\n../public/vue-assets/assets/snackbar-Bq-YQ7pz.js 5.48 kB │ gzip: 2.37 kB │ map: 20.43 kB\n../public/vue-assets/assets/RadioField-2SeSZB7D.js 5.65 kB │ gzip: 2.22 kB │ map: 15.82 kB\n../public/vue-assets/assets/filters-D_eXZdIL.js 5.76 kB │ gzip: 2.17 kB │ map: 16.36 kB\n../public/vue-assets/assets/useActivityHelper-BvWVN0nz.js 5.84 kB │ gzip: 2.51 kB │ map: 14.26 kB\n../public/vue-assets/assets/vue-scrollto-ul3pFdrb.js 5.97 kB │ gzip: 2.72 kB │ map: 23.98 kB\n../public/vue-assets/assets/join-conference-CLdXMCwD.js 6.25 kB │ gzip: 2.83 kB │ map: 24.66 kB\n../public/vue-assets/assets/pluralize-BeKVJaE0.js 6.34 kB │ gzip: 2.70 kB │ map: 18.28 kB\n../public/vue-assets/assets/login-PrWVOMsr.js 7.02 kB │ gzip: 3.17 kB │ map: 17.51 kB\n../public/vue-assets/assets/AppLinks-Bg20Hslb.js 7.14 kB │ gzip: 2.80 kB │ map: 14.87 kB\n../public/vue-assets/assets/InputField-CkdrqKBp.js 7.20 kB │ gzip: 2.58 kB │ map: 19.39 kB\n../public/vue-assets/assets/UserAvatar-UKkSAS4R.js 7.93 kB │ gzip: 3.32 kB │ map: 34.91 kB\n../public/vue-assets/assets/InputText-BH9WFuby.js 7.99 kB │ gzip: 3.05 kB │ map: 20.64 kB\n../public/vue-assets/assets/AiCrmNotes-DmPJHAz_.js 8.27 kB │ gzip: 3.32 kB │ map: 34.03 kB\n../public/vue-assets/assets/vue-mq-CmpUzQtD.js 8.84 kB │ gzip: 3.54 kB │ map: 23.69 kB\n../public/vue-assets/assets/pro-duotone-svg-icons-BSBZV3-t.js 9.51 kB │ gzip: 3.43 kB │ map: 3,146.68 kB\n../public/vue-assets/assets/activity-preview-result-CQou30yT.js 10.65 kB │ gzip: 3.98 kB │ map: 38.51 kB\n../public/vue-assets/assets/pro-regular-svg-icons-CWpSBR20.js 13.10 kB │ gzip: 4.64 kB │ map: 3,178.15 kB\n../public/vue-assets/assets/add-to-playlist-modal-BS8w3ovp.js 14.99 kB │ gzip: 5.98 kB │ map: 51.34 kB\n../public/vue-assets/assets/ai-reports-Cy_xmRg8.js 15.17 kB │ gzip: 6.04 kB │ map: 51.85 kB\n../public/vue-assets/assets/export-portal-CZ2b-FlS.js 15.68 kB │ gzip: 6.22 kB │ map: 52.78 kB\n../public/vue-assets/assets/Comment-CdvjTszI.js 15.92 kB │ gzip: 5.72 kB │ map: 39.96 kB\n../public/vue-assets/assets/useragent-DQ4F_O30.js 18.02 kB │ gzip: 8.06 kB │ map: 68.31 kB\n../public/vue-assets/assets/linkify-B_q6MgwI.js 18.58 kB │ gzip: 10.37 kB │ map: 88.73 kB\n../public/vue-assets/assets/ai-reports-manage-B1v5HdqS.js 18.92 kB │ gzip: 6.76 kB │ map: 66.14 kB\n../public/vue-assets/assets/dist-C2hisF0L.js 19.32 kB │ gzip: 7.87 kB │ map: 390.77 kB\n../public/vue-assets/assets/pro-solid-svg-icons-DBOkpln3.js 19.46 kB │ gzip: 6.72 kB │ map: 2,692.14 kB\n../public/vue-assets/assets/purify.es-BxOw6oE6.js 21.49 kB │ gzip: 8.71 kB │ map: 83.43 kB\n../public/vue-assets/assets/ActionItems-uKCSfiLo.js 21.92 kB │ gzip: 8.55 kB │ map: 76.05 kB\n../public/vue-assets/assets/mobile-zCtNIOGN.js 23.34 kB │ gzip: 9.71 kB │ map: 50.90 kB\n../public/vue-assets/assets/basic-modal-BdXsnFwC.js 23.87 kB │ gzip: 9.06 kB │ map: 64.15 kB\n../public/vue-assets/assets/GridView-D5_z7YdA.js 26.60 kB │ gzip: 10.05 kB │ map: 92.74 kB\n../public/vue-assets/assets/ondemand-mxFxJiHI.js 26.88 kB │ gzip: 9.39 kB │ map: 73.94 kB\n../public/vue-assets/assets/CrmLink-DKYsnHnx.js 27.91 kB │ gzip: 10.18 kB │ map: 93.18 kB\n../public/vue-assets/assets/liquor-tree-COUefof4.js 30.75 kB │ gzip: 9.58 kB │ map: 78.74 kB\n../public/vue-assets/assets/DealRiskList-D7gUVql5.js 34.39 kB │ gzip: 10.62 kB │ map: 115.18 kB\n../public/vue-assets/assets/AskAnything-DgI0NfRA.js 39.50 kB │ gzip: 14.97 kB │ map: 173.20 kB\n../public/vue-assets/assets/lib-CwM9toD2.js 39.69 kB │ gzip: 12.70 kB │ map: 138.34 kB\n../public/vue-assets/assets/AppFormField-BqQiLGFF.js 41.91 kB │ gzip: 12.69 kB │ map: 150.73 kB\n../public/vue-assets/assets/deal-view-D4YlPwr_.js 43.22 kB │ gzip: 14.35 kB │ map: 150.62 kB\n../public/vue-assets/assets/exports-D1lmea4O.js 47.84 kB │ gzip: 16.46 kB │ map: 294.48 kB\n../public/vue-assets/assets/playlists-BWAdERcJ.js 48.28 kB │ gzip: 15.07 kB │ map: 153.25 kB\n../public/vue-assets/assets/callScoringTemplates-zeRn40uL.js 55.13 kB │ gzip: 13.28 kB │ map: 65.85 kB\n../public/vue-assets/assets/_copyObject-USkOnlaQ.js 61.28 kB │ gzip: 20.09 kB │ map: 239.59 kB\n../public/vue-assets/assets/pusher-znYCfz7U.js 62.98 kB │ gzip: 18.88 kB │ map: 219.27 kB\n../public/vue-assets/assets/onboard-CyAPGoFk.js 63.11 kB │ gzip: 21.85 kB │ map: 201.38 kB\n../public/vue-assets/assets/StatusBadge-DNHiCr2i.js 64.66 kB │ gzip: 22.96 kB │ map: 244.72 kB\n../public/vue-assets/assets/kiosk-dfcpodo5.js 79.60 kB │ gzip: 22.65 kB │ map: 300.68 kB\n../public/vue-assets/assets/preload-helper-DCvhahzG.js 82.59 kB │ gzip: 27.46 kB │ map: 3,452.35 kB\n../public/vue-assets/assets/deal-insights-BVnPilVP.js 94.84 kB │ gzip: 28.17 kB │ map: 292.79 kB\n../public/vue-assets/assets/ListView-DJD6SV4A.js 115.71 kB │ gzip: 33.79 kB │ map: 308.10 kB\n../public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js 117.59 kB │ gzip: 38.71 kB │ map: 500.60 kB\n../public/vue-assets/assets/WelcomeLayout-B6wd32HG.js 120.67 kB │ gzip: 34.16 kB │ map: 258.56 kB\n../public/vue-assets/assets/dashboard-CsDOiLAi.js 128.71 kB │ gzip: 40.05 kB │ map: 410.48 kB\n../public/vue-assets/assets/emoji-input-CSq87OVy.js 129.28 kB │ gzip: 36.72 kB │ map: 266.15 kB\n../public/vue-assets/assets/AppButton-D3qMdODr.js 133.44 kB │ gzip: 43.03 kB │ map: 516.67 kB\n../public/vue-assets/assets/sentry-B3B1ZM6O.js 164.28 kB │ gzip: 52.24 kB │ map: 831.82 kB\n../public/vue-assets/assets/OrgSettingsLayout-DatDldIe.js 176.33 kB │ gzip: 56.15 kB │ map: 623.43 kB\n../public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js 180.40 kB │ gzip: 67.85 kB │ map: 836.88 kB\n../public/vue-assets/assets/playback-D1gm80qQ.js 198.79 kB │ gzip: 61.85 kB │ map: 684.87 kB\n../public/vue-assets/assets/index.module-Bjlhgfd1.js 218.14 kB │ gzip: 64.16 kB │ map: 1,108.20 kB\n../public/vue-assets/assets/intl-tel-input-BW4mv40Q.js 264.94 kB │ gzip: 60.31 kB │ map: 475.61 kB\n../public/vue-assets/assets/team-insights-DRugjYCA.js 298.57 kB │ gzip: 77.22 kB │ map: 959.96 kB\n../public/vue-assets/assets/popper-CQwVcrX4.js 307.13 kB │ gzip: 103.86 kB │ map: 1,245.28 kB\n../public/vue-assets/assets/PhoneField-CwCIoAYm.js 343.99 kB │ gzip: 84.90 kB │ map: 849.05 kB\n../public/vue-assets/assets/live-CxSmZv7h.js 367.43 kB │ gzip: 97.05 kB │ map: 792.41 kB\n../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BNO485xV.js 689.63 kB │ gzip: 202.81 kB │ map: 3,016.64 kB\n../public/vue-assets/assets/index-Cp0YOK4U.js 825.23 kB │ gzip: 72.54 kB │ map: 436.62 kB\n../public/vue-assets/assets/logged-in-layout-CE9ox17M.js 1,402.70 kB │ gzip: 438.07 kB │ map: 6,283.55 kB\n\n✓ built in 26.01s\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.\nlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-token-auth-response-change) $","is_focused":true},{"role":"AXRadioButton","text":"DOCKER","depth":2,"bounds":{"left":0.00069444446,"top":0.06,"width":0.12291667,"height":0.026666667},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.0048611113,"top":0.064444445,"width":0.011111111,"height":0.017777778},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"DEV (docker)","depth":2,"bounds":{"left":0.12361111,"top":0.06,"width":0.12291667,"height":0.026666667},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.12777779,"top":0.064444445,"width":0.011111111,"height":0.017777778},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"APP (-zsh)","depth":2,"bounds":{"left":0.24652778,"top":0.06,"width":0.12291667,"height":0.026666667},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.25069445,"top":0.064444445,"width":0.011111111,"height":0.017777778},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.36944443,"top":0.06,"width":0.12291667,"height":0.026666667},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.37361112,"top":0.064444445,"width":0.011111111,"height":0.017777778},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"-zsh","depth":2,"bounds":{"left":0.4923611,"top":0.06,"width":0.12291667,"height":0.026666667},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.4965278,"top":0.064444445,"width":0.011111111,"height":0.017777778},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"✳ Review screenpipe usage and Boosteroid integration (claude)","depth":2,"bounds":{"left":0.61527777,"top":0.06,"width":0.12291667,"height":0.026666667},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.61944443,"top":0.064444445,"width":0.011111111,"height":0.017777778},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ec2-user@ip-10-30-159-186:~ (nc)","depth":2,"bounds":{"left":0.73819447,"top":0.06,"width":0.12291667,"height":0.026666667},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.7423611,"top":0.064444445,"width":0.011111111,"height":0.017777778},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"ec2-user@ip-10-20-6-111:~ (nc)","depth":2,"bounds":{"left":0.8611111,"top":0.06,"width":0.12291667,"height":0.026666667},"role_description":"radio button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close Tab","depth":3,"bounds":{"left":0.86527777,"top":0.064444445,"width":0.011111111,"height":0.017777778},"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"⌥⌘1","depth":1,"bounds":{"left":0.95555556,"top":0.033333335,"width":0.03888889,"height":0.018888889},"automation_id":"_NS:8","role_description":"text"},{"role":"AXStaticText","text":"APP (-zsh)","depth":1,"bounds":{"left":0.475,"top":0.034444444,"width":0.05138889,"height":0.017777778},"role_description":"text"}]...
|
-2407191479157132203
|
3429803486563842285
|
idle
|
accessibility
|
NULL
|
info Visit [URL_WITH_CREDENTIALS] ~/jiminny/app/fr info Visit [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yanrn build
zsh: command not found: yanrn
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn build
[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚡️ secrets for agents: [URL_WITH_CREDENTIALS] ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn install
➤ YN0000: · Yarn 4.12.0
➤ YN0000: ┌ Resolution step
➤ YN0000: └ Completed
➤ YN0000: ┌ Post-resolution validation
➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide @testing-library/dom (pea6ced), requested by @testing-library/user-event.
➤ YN0002: │ root-workspace-0b6124@workspace:. doesn't provide noty (p5bdb0d), requested by vue-components.
➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.
➤ YN0086: │ Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.
➤ YN0000: └ Completed
➤ YN0000: ┌ Fetch step
➤ YN0000: └ Completed in 1s 655ms
➤ YN0000: ┌ Link step
➤ YN0008: │ root-workspace-0b6124@workspace:. must be rebuilt because its dependency tree changed
➤ YN0000: └ Completed in 0s 969ms
➤ YN0000: · Done with warnings in 3s 14ms
lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app/front-end (JY-20692-fix-integration-app-[API_KEY]) $ yarn build
[dotenv@17.3.1] injecting env (0) from .env -- tip: ⚙️ override existing env vars with { override: true }
vite v8.0.0 building client environment for production...
✓ 4656 modules transformed.
[sentry-vite-plugin] Warning: No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/
[sentry-vite-plugin] Warning: No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/
computing gzip size...
../public/vue-assets/index.html 4.58 kB │ gzip: 1.15 kB
../public/vue-assets/assets/job-adder-B2LcgC4o.png 5.41 kB
../public/vue-assets/assets/dixa-DzT89tKh.png 7.87 kB
../public/vue-assets/assets/planhat-CQycOTMW.svg 8.61 kB │ gzip: 3.48 kB
../public/vue-assets/assets/funding-circle-9iGnDGsz.png 9.43 kB
../public/vue-assets/assets/cision-uOdx_YiN.svg 14.41 kB │ gzip: 6.32 kB
../public/vue-assets/assets/les-mills-DWabpmYc.png 15.73 kB
../public/vue-assets/assets/superside-btT37L9L.png 26.69 kB
../public/vue-assets/assets/flags-a2kmUSbF.webp 28.18 kB
../public/vue-assets/assets/quinyx-RGQNHx6o.png 63.17 kB
../public/vue-assets/assets/[EMAIL] 66.44 kB
../public/vue-assets/assets/bg-marketing-CAIHIJMj.png 70.66 kB
../public/vue-assets/.vite/manifest.json 85.73 kB │ gzip: 8.32 kB
../public/vue-assets/assets/jiminny-score-DtTbKZaD.svg 131.93 kB │ gzip: 86.07 kB
../public/vue-assets/assets/wavy-bg-vnCaCm9m.webm 670.97 kB
../public/vue-assets/assets/wavy-bg-Op6dRIjR.mp4 14,484.63 kB
../public/vue-assets/assets/AppForm-EEGPL5K8.css 0.04 kB │ gzip: 0.06 kB
../public/vue-assets/assets/RecipientsCell-DNOSS8k1.css 0.12 kB │ gzip: 0.12 kB
../public/vue-assets/assets/softphone-coach-Cy_9nuQZ.css 0.13 kB │ gzip: 0.10 kB
../public/vue-assets/assets/locked-CHS8VPur.css 0.15 kB │ gzip: 0.12 kB
../public/vue-assets/assets/mobile-KPnGhnTp.css 0.16 kB │ gzip: 0.14 kB
../public/vue-assets/assets/BuildInfo-CxNQv_OV.css 0.20 kB │ gzip: 0.17 kB
../public/vue-assets/assets/AvatarsStack-C0Nvfh1V.css 0.28 kB │ gzip: 0.20 kB
../public/vue-assets/assets/LogoLong100-ByQTvUY3.css 0.32 kB │ gzip: 0.20 kB
../public/vue-assets/assets/connect--xBDzLbc.css 0.34 kB │ gzip: 0.21 kB
../public/vue-assets/assets/KioskBanner-xU3FRd8g.css 0.41 kB │ gzip: 0.27 kB
../public/vue-assets/assets/extension-installed-BPBhG13P.css 0.41 kB │ gzip: 0.26 kB
../public/vue-assets/assets/FollowModal-A0rJmIOK.css 0.45 kB │ gzip: 0.23 kB
../public/vue-assets/assets/GenericMessage-DutAlObK.css 0.49 kB │ gzip: 0.26 kB
../public/vue-assets/assets/textarea-caret-DCiNUER_.css 0.51 kB │ gzip: 0.32 kB
../public/vue-assets/assets/AppLinks-BznfGcBR.css 0.58 kB │ gzip: 0.33 kB
../public/vue-assets/assets/SeekBtn-_VMPIk6v.css 0.63 kB │ gzip: 0.32 kB
../public/vue-assets/assets/emoji-input-kJWRbWf8.css 0.68 kB │ gzip: 0.38 kB
../public/vue-assets/assets/activity-preview-BDxP2S0-.css 0.70 kB │ gzip: 0.41 kB
../public/vue-assets/assets/ListLoader-DQqAw-8s.css 0.73 kB │ gzip: 0.38 kB
../public/vue-assets/assets/filters-Bgb6cQD6.css 0.75 kB │ gzip: 0.39 kB
../public/vue-assets/assets/AiContext-Bg-zjjbA.css 0.99 kB │ gzip: 0.45 kB
../public/vue-assets/assets/vue3-daterange-picker-izPOBj3P.css 1.02 kB │ gzip: 0.39 kB
../public/vue-assets/assets/PrimaryButton-CJj2FzKS.css 1.08 kB │ gzip: 0.34 kB
../public/vue-assets/assets/other-BTuPXtu_.css 1.15 kB │ gzip: 0.49 kB
../public/vue-assets/assets/jenesius-vue-modal-DDcfejHO.css 1.17 kB │ gzip: 0.48 kB
../public/vue-assets/assets/TabEmptyState-DOlIO68k.css 1.21 kB │ gzip: 0.53 kB
../public/vue-assets/assets/AppAlert-BlSJKiqj.css 1.29 kB │ gzip: 0.41 kB
../public/vue-assets/assets/useActivityCustomerName-CBjfIUfJ.css 1.42 kB │ gzip: 0.45 kB
../public/vue-assets/assets/UserAvatar-co8l9mMk.css 1.51 kB │ gzip: 0.49 kB
../public/vue-assets/assets/basic-modal-Dx7ZBbcA.css 1.55 kB │ gzip: 0.66 kB
../public/vue-assets/assets/login-DYbcBesM.css 1.58 kB │ gzip: 0.61 kB
../public/vue-assets/assets/DrawerWidget-poAmqspk.css 1.67 kB │ gzip: 0.69 kB
../public/vue-assets/assets/ActionItems-5FkZSo3G.css 1.79 kB │ gzip: 0.80 kB
../public/vue-assets/assets/join-conference-DQ6Yl7eU.css 1.82 kB │ gzip: 0.75 kB
../public/vue-assets/assets/InputText-C-DxDNSV.css 1.95 kB │ gzip: 0.71 kB
../public/vue-assets/assets/GoogleLikeButton-DvQHxbqR.css 2.11 kB │ gzip: 0.66 kB
../public/vue-assets/assets/usePusherEventListener-CDj8aSNp.css 2.20 kB │ gzip: 0.73 kB
../public/vue-assets/assets/onboard-IzGiQ-EC.css 2.32 kB │ gzip: 0.95 kB
../public/vue-assets/assets/ai-reports-manage-DaynTl62.css 2.34 kB │ gzip: 0.86 kB
../public/vue-assets/assets/StatusBadge-B4N12dzU.css 2.35 kB │ gzip: 0.86 kB
../public/vue-assets/assets/GridView-CsP1Jk-k.css 2.36 kB │ gzip: 0.90 kB
../public/vue-assets/assets/WelcomeLayout-6AX86p6F.css 2.38 kB │ gzip: 0.91 kB
../public/vue-assets/assets/snackbar-CS_iqvrv.css 2.45 kB │ gzip: 0.80 kB
../public/vue-assets/assets/ai-reports-mtqGqPPq.css 2.47 kB │ gzip: 0.91 kB
../public/vue-assets/assets/Comment-gmAfiuer.css 2.63 kB │ gzip: 1.00 kB
../public/vue-assets/assets/meeting-consent-BasRvxE3.css 2.66 kB │ gzip: 0.96 kB
../public/vue-assets/assets/DealRiskList-CwxmDnSC.css 2.67 kB │ gzip: 0.96 kB
../public/vue-assets/assets/add-to-playlist-modal-C_ad1hht.css 2.84 kB │ gzip: 0.95 kB
../public/vue-assets/assets/live-_zT5qBc1.css 3.60 kB │ gzip: 1.16 kB
../public/vue-assets/assets/RadioField-DDp8Y71G.css 3.64 kB │ gzip: 1.20 kB
../public/vue-assets/assets/vue-multiselect-BkQNV7oL.css 3.80 kB │ gzip: 0.86 kB
../public/vue-assets/assets/activity-preview-result-Db1Da81T.css 3.89 kB │ gzip: 1.15 kB
../public/vue-assets/assets/liquor-tree-CB5oh8v1.css 4.08 kB │ gzip: 1.24 kB
../public/vue-assets/assets/sentry-CSjHcvmn.css 4.47 kB │ gzip: 1.08 kB
../public/vue-assets/assets/AiAutomation-Cj1t8v5y.css 4.61 kB │ gzip: 0.84 kB
../public/vue-assets/assets/export-portal-CyIUVTEe.css 5.34 kB │ gzip: 1.65 kB
../public/vue-assets/assets/InputField-C4hoKJ7Z.css 6.13 kB │ gzip: 1.27 kB
../public/vue-assets/assets/vue-mq-bh4L87Tr.css 6.64 kB │ gzip: 1.95 kB
../public/vue-assets/assets/invitation-EPR1t-bH.css 6.74 kB │ gzip: 2.22 kB
../public/vue-assets/assets/ondemand-C2m0YVGA.css 6.84 kB │ gzip: 2.02 kB
../public/vue-assets/assets/AskAnything-CyvEhHS1.css 8.60 kB │ gzip: 2.60 kB
../public/vue-assets/assets/AppButton-BIeoar_U.css 8.88 kB │ gzip: 2.04 kB
../public/vue-assets/assets/kiosk-BcheyWWL.css 9.56 kB │ gzip: 2.53 kB
../public/vue-assets/assets/AiCrmNotes-nNTe_mOY.css 10.62 kB │ gzip: 1.93 kB
../public/vue-assets/assets/deal-view-DJzG7PLp.css 11.70 kB │ gzip: 3.29 kB
../public/vue-assets/assets/tokens-DZgWrfKd.css 12.44 kB │ gzip: 2.82 kB
../public/vue-assets/assets/tokens-DL2BPbai.css 12.89 kB │ gzip: 2.87 kB
../public/vue-assets/assets/playlists-Cy3t3kYU.css 13.54 kB │ gzip: 3.16 kB
../public/vue-assets/assets/PhoneField-D_kGOah0.css 13.75 kB │ gzip: 3.44 kB
../public/vue-assets/assets/ListView-Dt0qkyuY.css 17.07 kB │ gzip: 4.11 kB
../public/vue-assets/assets/intl-tel-input-DgmgTINs.css 17.11 kB │ gzip: 5.37 kB
../public/vue-assets/assets/AppFormField-DvpfRzi8.css 18.98 kB │ gzip: 4.42 kB
../public/vue-assets/assets/OrgSettingsLayout-BogdXtgY.css 21.98 kB │ gzip: 5.51 kB
../public/vue-assets/assets/dashboard-DQw3TKyk.css 22.74 kB │ gzip: 4.35 kB
../public/vue-assets/assets/deal-insights-CfX3UNzh.css 29.66 kB │ gzip: 6.44 kB
../public/vue-assets/assets/team-insights-CjVhm0JN.css 42.31 kB │ gzip: 8.46 kB
../public/vue-assets/assets/playback-CvJP5tX1.css 44.55 kB │ gzip: 10.36 kB
../public/vue-assets/assets/video-js-skin-DYZluGb-.css 47.73 kB │ gzip: 12.63 kB
../public/vue-assets/assets/assets-CAbfI4CY.css 83.38 kB │ gzip: 51.71 kB
../public/vue-assets/assets/logged-in-layout-1mAqkmnS.css 153.67 kB │ gzip: 27.18 kB
../public/vue-assets/assets/assets-xH6dx_9q.css 259.71 kB │ gzip: 181.71 kB
../public/vue-assets/assets/directives-DJJeJJOP.js 0.53 kB │ gzip: 0.36 kB │ map: 0.32 kB
../public/vue-assets/assets/jenesius-vue-modal-BuBhyl83.js 0.54 kB │ gzip: 0.37 kB │ map: 0.47 kB
../public/vue-assets/assets/spark-D_-Wgfar.js 0.54 kB │ gzip: 0.35 kB │ map: 0.40 kB
../public/vue-assets/assets/url-messenger-_CGQa-lH.js 0.56 kB │ gzip: 0.37 kB │ map: 0.46 kB
../public/vue-assets/assets/wavy-bg-Cec-_GDj.js 0.57 kB │ gzip: 0.36 kB │ map: 0.35 kB
../public/vue-assets/assets/component-css-class-CtO0AVgW.js 0.61 kB │ gzip: 0.40 kB │ map: 0.91 kB
../public/vue-assets/assets/theme-B5VdyMN_.js 0.63 kB │ gzip: 0.41 kB │ map: 0.49 kB
../public/vue-assets/assets/pick-C8Pw7kHx.js 0.71 kB │ gzip: 0.45 kB │ map: 1.59 kB
../public/vue-assets/assets/utils-BjsEoLDB.js 0.77 kB │ gzip: 0.50 kB │ map: 1.68 kB
../public/vue-assets/assets/throttle-siTOKEMt.js 0.78 kB │ gzip: 0.49 kB │ map: 3.24 kB
../public/vue-assets/assets/lastFilters-B_B_O5LY.js 0.80 kB │ gzip: 0.51 kB │ map: 1.12 kB
../public/vue-assets/assets/useAuthState-Bpx8WpBc.js 0.81 kB │ gz...
|
46615
|
|
46772
|
NULL
|
0
|
2026-04-17T10:47:39.299823+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776422859299_m2.jpg...
|
PhpStorm
|
faVsco.js – SF [jiminny@localhost]
|
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...
|
[{"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}]...
|
-7665353928652150759
|
-7771723620485485626
|
visual_change
|
hybrid
|
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
PhpStormFileEditViewNavigateCodeLaravelRefactorFV faVsco.s vT° JY-20692-fix-integration-app-[API_KEY].php-cs-fixer.dist.phpphp.phpstorm.meta.php=.phpunit.result.cacheE.prettierignoreE windsurfrulespip _lde_nelper.pnppnpce neloermoces.onepnp aruisani composer.isonO composer.lockdependency-checker.jsonO dev.jsonE ids.txt=infection.json.distM-INSALL.mdM+ INTERNAL WEBHOOK SETUPjiminny_storageM+ licenses.mdM MakefileOpackage-lock.json= phpstan.neon.dist= phpstan-baseline.neon< phpunit.xmlTa raw_sqLquery.sqlM+ README.mdsonar-project.properties= test.py<> Untitled Diagram.xmlI5 vetur.config.jsM-+ WEbnOOK HILIEKING_IMPLE› Iib External Librariesv E® Scratches and Consolesv D Database Consoles© AutomatedReportsService.php© SendReportJob.phpC ReportController.phplokenbullaer.onoc leamsetuocontroller.onp• Filesystem.php© Team.php© RequestGenerateReportJob.phpT InteractsWithPivotTable.phg© CreateHeldActivityEvent.phpOpportunitySyncTrait.phpC OpportunityUpdated.php© SendReportMailJob.phpphp api.php© TrackProviderInstalledEvent.phpC Opportunity.phpC OpportunityStageUpdated.phpc) FventService?rovider.onn© RunOpportunityAiAnalysis.php( ImportBatchJobTrait.php© OpportunityPendingAiAnalysisAfterStageChanged.php© ProcessAiAutomationAnalysisResults.php(C) Service.php© ImportOpportunityBatch.php101819281030cachedStagesCcW165trait OpportunitySyncTraitA33 X2 X19 A Y 166private function resolveForecastCategory(?string $forecastCategory): stringf...1672 usages=169private function importExternalFieldData(array $properties, int $opportunitylra, 170171$crmFields = $this->getOpportunitySyncableFieldsO;=172$this->import0pportunityCrmFieldData(Sproperties, $crmFields, Sopportunityi17310331034103510481049105010511052Lusaeesprivate function importOpportunityContacts(Opportunity Sopportunity, array $as:177/*** Sync opportunity contacts using differential approach* This compares current vs new associations and only makes necessary changes=175176=1791801811821 usageprivate function syncOpportunityContactsDifferential(Opportunity $opportunity,1070private function getcurrentContactCrmIds(Opportunity $opportunity): array{...7 187A console [EU]4 DEAL RISKS [EU]A DI [EU]dEuleUv& jiminny@localhost& console yiminny @localrA DI [jiminny@localhost]4 HS_local [jiminny@localA SF [jiminny@localhost]A zoho_dev [jiminny@loceY A PRODД0/110771078107910801 usageprivate function logContactAssociationChanges(Opportunity Sopportunity,array $currentContactCrmIds,array $contactAssociations,array $contactsToAdd,array +concacustokemove): void {...}-189190191192—193services+,o,cv D DatabasevdtuA console 1 s 59 msu crm_contigurations 1s 391 msv & minnvo ocanost4SF4 HS_localV A PROD4 console 1 s 806 msV A STAGING& consoleOutputiD # 1695 X3 rowsS9T2_ ICIsImtpzC16l7VmNwrhzGFKLT0wzDKtNDLKNy04ZWI2LTAZNmNLM.FQG0A• expires Y177633617617296136151776509176I refresh_token_expires Y3 rows retrieved starting from 1 in 430 ms (execution: 40 ms, fetching: 390 ms)lablf Support Daily • in 1h 13 mAAutomatedRenortsCommandTest100% 145Fri 17 Apr 13:47:38= custom.log< Hs local liminnyalocalnostA console [PROD]E laravel.logA SF [jiminny@localhost] XC scratch_1.jsonA console (EU]© CrmEntityRepository.phpA console [STAGING]V connect.vuefi crm_configurations (EU]V Onboard.vueCascadeAutomated Report RetrDebugging OpportunitUpdate Connection Loconnect.vue tront-end/src/components/connecXAutovPlaygroundSELECT * FROM automated_reports order by za desc;SELECT * FROMautomated_report_results order by id desc;select * from activity_searches where user_id = 143;select * from ask_anything_prompts;ma liminnvvM09412 X2 X4AVnothing is therew"cant see log'sSELECT * FROM groups WHERE id = 1439;SELECT * FROM users WHERE group_id = 1439;select * from permissions; # 158select * from roles;select * from permission_role,The console is full of CSP warnings and errors but none of your [IntegrationApp] logs. This means intedrat1onApponcL1ck Is never belno called at all — the burton click isn't reachine that tunction.Check:1. Is the button actually calling integrationApp0nClick? - the click handler might be bound to adifferent method or the component re-rendered2. Is the frontend bundle rebuilt? — you may be running a stale build that doesn't include your changesselect * from teams where id = 1;select * from groups g JOIN playbooks p 1.n<->1: on g.playbook_id = p.id where g.team_id = 1;select * from groups where id = 28;select * from playbooks where team_id = 1;select * from playbooks where id = 179;select * from playbook_categories where id = 1391;select * from users where id = 143;select * from crm_profiles where user_id = 143;select * from activities where crm_configuration_id = 39 and type = 'conference"and crm_provider_id IS NOT NULL ORDER by id desc;select * from activities where id = 422003; # 00U0400000pB6FpMACSearched.intecrationAoo@nClickin tront-enc/src/cents/connect/connect.vue (2)The binding looks correct. The most likely issue is a stale build - your changes aren't compiled yet. Add aquick test: put a console.log before the IntegrationAppClient instantiation that's impossible to miss:Reao connectvue :158-|44• connect.vueNow after saving, hard refresh the browser ( Cmd+Shift+R ) to bust the cache. If you still don't see [Integratlonado incecrac1onApouncllck called arter cllckine tne outton, tne dev server nasn't olckea up tnechanges or the click event isn't reaching this component (e.g. another element is intercepting it).SELECT ar.ig, ar.wuig, ar.mediantxpe, ar.status, a.txReFROM automated_report_results arJOIN automated_reports a ON a.id = ar.repontaidWHERE a.IxRe = 'ask_iiminnx'LIMIT 10;select * from teams where id = 3143;select * from crm_configurations where id = 500;select * from users where name = 'Integration Account'; # 1695SELECT * FROM social_accounts WHERE sociable_id = 1695;etI to CascadeAsk anything (284L)+ <> Code Claude Sonnet 4.6. provider Yu cooqle<null> zoom-phone<null> integration-app! state Ytuu-retresnfull-refreshfull-refreshWauth_scope Yopenid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/calendar http.phone:read:admin user:read:admin<null>• :-CSV vДOB,retry_after Y00 cr2020<nul><null>20242026-W Windsurf Teams 193:1 0 UTF-84 spaces...
|
46771
|
|
46773
|
NULL
|
0
|
2026-04-17T10:47:39.696274+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776422859696_m1.jpg...
|
PhpStorm
|
faVsco.js – SF [jiminny@localhost]
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelplall= Support Daily - in 1 h 13 m100% <47APP (-zsh)X4-zshDOCKER• ₴1DEV (docker)• *2APP (-zsh)X3-zsh./public/vue-assets/assets/ondemand-mxFxJiHI.js./public/vue-assets/assets/CrmLink-DKYsnHnx.js./public/vue-assets/assets/liquor-tree-COUefof4.js./public/vue-assets/assets/DealRiskList-D7gUVql5.js:./public/vue-assets/assets/AskAnything-DgI0NfRA.js:./public/vue-assets/assets/lib-CwM9toD2.js./public/vue-assets/assets/AppFormField-BqQiLGFF.js./public/vue-assets/assets/deal-view-D4YlPwr_.js:./public/vue-assets/assets/exports-D1lmea40.js../public/vue-assets/assets/playlists-BWAdERcJ.js../public/vue-assets/assets/callScoringTemplates-zeRn40ul.js../public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js./public/vue-assets/assets/onboard-CyAPGoFk.js./public/vue-assets/assets/StatusBadge-DNHiCr2i.js./public/vue-assets/assets/kiosk-dfcpodo5.js./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-BVnPilVP.js../public/vue-assets/assets/ListView-DJD6SV4A.js:./public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js:./public/vue-assets/assets/WelcomeLayout-B6wd32HG.js../public/vue-assets/assets/dashboard-CsD0iLAi.js:./public/vue-assets/assets/emoji-input-CSq87OVy.js../public/vue-assets/assets/AppButton-D3qMdODr.js../public/vue-assets/assets/sentry-B3B12M60.js:./public/vue-assets/assets/OrgSettingsLayout-DatDldIe.js./public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js./public/vue-assets/assets/playback-D1gm80qQ.js./public/vue-assets/assets/index.module-Bjlhgfdl.js./public/vue-assets/assets/intl-tel-input-BW4mv40Q.js:./public/vue-assets/assets/team-insights-DRugjYCA.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js./public/vue-assets/assets/live-CxSmZv7h.js../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-Cp0YOK4U.js../public/vue-assets/assets/logged-in-layout-CE9ox17M.js• ₴526.88kB27.91kB30.75kB34.39kB39.50kB39.69kB41.91kB43.22kB47.84kB48.28kB55.13kB61.28kB62.98kB63.11kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59 kВ120.67kB128.71 kB129.28 kB133.44 kB164.28kB176.33kB180.40kB198.79kB218.14kB264.94 kВ298.57kB307.13kB343.99kB367.43 kB689.63kB825.23 kB1,402.70 kB• built in 26.01s[plugin builtin:vite-reporter](!) Some chunksare larger than 500 kB after minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldown0ptions.output.codeSplitting to improve chunking: [URL_WITH_CREDENTIALS] 88APP...
|
NULL
|
-5025856933011940221
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelplall= Support Daily - in 1 h 13 m100% <47APP (-zsh)X4-zshDOCKER• ₴1DEV (docker)• *2APP (-zsh)X3-zsh./public/vue-assets/assets/ondemand-mxFxJiHI.js./public/vue-assets/assets/CrmLink-DKYsnHnx.js./public/vue-assets/assets/liquor-tree-COUefof4.js./public/vue-assets/assets/DealRiskList-D7gUVql5.js:./public/vue-assets/assets/AskAnything-DgI0NfRA.js:./public/vue-assets/assets/lib-CwM9toD2.js./public/vue-assets/assets/AppFormField-BqQiLGFF.js./public/vue-assets/assets/deal-view-D4YlPwr_.js:./public/vue-assets/assets/exports-D1lmea40.js../public/vue-assets/assets/playlists-BWAdERcJ.js../public/vue-assets/assets/callScoringTemplates-zeRn40ul.js../public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js./public/vue-assets/assets/onboard-CyAPGoFk.js./public/vue-assets/assets/StatusBadge-DNHiCr2i.js./public/vue-assets/assets/kiosk-dfcpodo5.js./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-BVnPilVP.js../public/vue-assets/assets/ListView-DJD6SV4A.js:./public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js:./public/vue-assets/assets/WelcomeLayout-B6wd32HG.js../public/vue-assets/assets/dashboard-CsD0iLAi.js:./public/vue-assets/assets/emoji-input-CSq87OVy.js../public/vue-assets/assets/AppButton-D3qMdODr.js../public/vue-assets/assets/sentry-B3B12M60.js:./public/vue-assets/assets/OrgSettingsLayout-DatDldIe.js./public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js./public/vue-assets/assets/playback-D1gm80qQ.js./public/vue-assets/assets/index.module-Bjlhgfdl.js./public/vue-assets/assets/intl-tel-input-BW4mv40Q.js:./public/vue-assets/assets/team-insights-DRugjYCA.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js./public/vue-assets/assets/live-CxSmZv7h.js../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-Cp0YOK4U.js../public/vue-assets/assets/logged-in-layout-CE9ox17M.js• ₴526.88kB27.91kB30.75kB34.39kB39.50kB39.69kB41.91kB43.22kB47.84kB48.28kB55.13kB61.28kB62.98kB63.11kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59 kВ120.67kB128.71 kB129.28 kB133.44 kB164.28kB176.33kB180.40kB198.79kB218.14kB264.94 kВ298.57kB307.13kB343.99kB367.43 kB689.63kB825.23 kB1,402.70 kB• built in 26.01s[plugin builtin:vite-reporter](!) Some chunksare larger than 500 kB after minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldown0ptions.output.codeSplitting to improve chunking: [URL_WITH_CREDENTIALS] 88APP...
|
NULL
|
|
46925
|
NULL
|
0
|
2026-04-17T10:52:12.907193+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423132907_m1.jpg...
|
Firefox
|
Jiminny — Work
|
True
|
app.dev.jiminny.com/dashboard
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"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":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Jiminny","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":"Open bookmarks (⌘B)","depth":6,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-4465696449359315208
|
-2648858699392996788
|
click
|
accessibility
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)...
|
NULL
|
|
46933
|
NULL
|
0
|
2026-04-17T10:52:44.201703+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423164201_m2.jpg...
|
Firefox
|
Jiminny — Work
|
True
|
app.dev.jiminny.com/dashboard
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Close tab
New Tab
Customize sidebar
Close Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
AI Chat settings
Close
WORK, Google Account: [EMAIL]
Main menu
New chat
Gemini
PRO
PRO
Conversation with Gemini
Conversation with Gemini
Copy prompt
Edit
You said firefox how to preseve console
You said
firefox how to preseve console
Show more options
Enter a prompt for Gemini
encrypted
Enter a prompt for Gemini
encrypted
Open upload file menu
Tools
Open mode picker
Pro
Stop response
Your Jiminny chats aren’t used to improve our models. Gemini is AI and can make mistakes, including about people.
Your privacy & Gemini Opens in a new window
Your privacy & Gemini
Opens in a new window
Gemini is typing
Summarize page
Summarize page
install
My Recordings
My Recordings
Everyone's Recordings
Everyone's Recordings
No Recordings
Schedule
Schedule
Invite Notetaker
This Week
This Week
My Schedule
My Schedule
No Meetings
Trending this month
Trending this month
Sort by Most played
Sort by
Most played
No Recordings
Live Feed
Live Feed
No Activity
Clear the Web Console output (⌘K, Ctrl+L)
Filter Output
Errors
Warnings
Info
Logs
Debug
CSS
XHR
Requests
Console Settings
Show/hide message details.
GET
[URL_WITH_CREDENTIALS]
[URL_WITH_CREDENTIALS]
[HTTP/2
200
OK 0ms]
Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at
https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAUi-qNiXg7eU0.woff2
https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAUi-qNiXg7eU0.woff2
because it violates the following directive: “font-src 'self'
https://app.dev.jiminny.com
https://app.dev.jiminny.com
https://app.dev.jiminny.com/
https://app.dev.jiminny.com/
https://js.intercomcdn.com
https://js.intercomcdn.com
”
dashboard
dashboard
Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at
https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAXC-qNiXg7Q.woff2
https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAXC-qNiXg7Q.woff2
because it violates the following directive: “font-src 'self'
https://app.dev.jiminny.com
https://app.dev.jiminny.com
https://app.dev.jiminny.com/
https://app.dev.jiminny.com/
https://js.intercomcdn.com
https://js.intercomcdn.com
”
dashboard
dashboard
Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at
https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_FQftx9897sxZ.woff2
https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_FQftx9897sxZ.woff2
because it violates the following directive: “font-src 'self'
https://app.dev.jiminny.com
https://app.dev.jiminny.com
https://app.dev.jiminny.com/
https://app.dev.jiminny.com/
https://js.intercomcdn.com
https://js.intercomcdn.com
”
dashboard
dashboard
Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at
https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_Gwftx9897g.woff2
https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_Gwftx9897g.woff2
because it violates the following directive: “font-src 'self'
https://app.dev.jiminny.com
https://app.dev.jiminny.com
https://app.dev.jiminny.com/
https://app.dev.jiminny.com/
https://js.intercomcdn.com
https://js.intercomcdn.com
”
dashboard...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"bounds":{"left":0.00234375,"top":0.045138888,"width":0.0890625,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"bounds":{"left":0.0,"top":0.08263889,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"bounds":{"left":0.015625,"top":0.09236111,"width":0.04453125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.0,"top":0.11111111,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.015625,"top":0.12083333,"width":0.11445312,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.0,"top":0.13958333,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.015625,"top":0.14930555,"width":0.17734376,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"bounds":{"left":0.0,"top":0.16805555,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"bounds":{"left":0.015625,"top":0.17777778,"width":0.048828125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.19652778,"width":0.09375,"height":0.028472222},"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.015625,"top":0.20625,"width":0.017578125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"bounds":{"left":0.0,"top":0.225,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"bounds":{"left":0.015625,"top":0.23472223,"width":0.1515625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2534722,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"bounds":{"left":0.015625,"top":0.26319444,"width":0.17421874,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"bounds":{"left":0.0,"top":0.28194445,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"bounds":{"left":0.015625,"top":0.29166666,"width":0.09726562,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.0,"top":0.31041667,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Jiminny","depth":5,"bounds":{"left":0.015625,"top":0.3201389,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.07890625,"top":0.31666666,"width":0.009375,"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.003125,"top":0.3402778,"width":0.08710937,"height":0.022222223},"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.003125,"top":0.97430557,"width":0.0125,"height":0.022222223},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Close Google Gemini (⌃X)","depth":6,"bounds":{"left":0.01640625,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.029296875,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.0421875,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.05546875,"top":0.97430557,"width":0.0125,"height":0.022222223},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"AI Chat settings","depth":7,"bounds":{"left":0.2171875,"top":0.047916666,"width":0.0125,"height":0.022222223},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":7,"bounds":{"left":0.23125,"top":0.047916666,"width":0.0125,"height":0.022222223},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"WORK, Google Account: lukas.kovalik@jiminny.com","depth":12,"bounds":{"left":0.228125,"top":0.090277776,"width":0.015625,"height":0.027777778},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Main menu","depth":12,"bounds":{"left":0.0984375,"top":0.090277776,"width":0.015625,"height":0.027777778},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New chat","depth":12,"bounds":{"left":0.1140625,"top":0.09097222,"width":0.03359375,"height":0.02638889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Gemini","depth":15,"bounds":{"left":0.1171875,"top":0.09513889,"width":0.02578125,"height":0.018055556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"PRO","depth":11,"bounds":{"left":0.20820312,"top":0.09583333,"width":0.017578125,"height":0.016666668},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"PRO","depth":13,"bounds":{"left":0.21132812,"top":0.09791667,"width":0.011328125,"height":0.013194445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Conversation with Gemini","depth":15,"bounds":{"left":0.09335937,"top":0.12847222,"width":0.000390625,"height":0.00069444446},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Conversation with Gemini","depth":16,"bounds":{"left":0.09335937,"top":0.13055556,"width":0.14101562,"height":0.022222223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy prompt","depth":20,"bounds":{"left":0.1203125,"top":0.15972222,"width":0.015625,"height":0.027777778},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Edit","depth":20,"bounds":{"left":0.1375,"top":0.15972222,"width":0.015625,"height":0.027777778},"role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"You said firefox how to preseve console","depth":20,"bounds":{"left":0.1609375,"top":0.16805555,"width":0.072265625,"height":0.03888889},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"You said","depth":22,"bounds":{"left":0.09335937,"top":0.1701389,"width":0.0234375,"height":0.014583333},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"firefox how to preseve console","depth":22,"bounds":{"left":0.1609375,"top":0.17083333,"width":0.06289063,"height":0.034027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show more options","depth":18,"bounds":{"left":0.22382812,"top":0.23194444,"width":0.015625,"height":0.027777778},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXTextArea","text":"Enter a prompt for Gemini\nencrypted","depth":20,"bounds":{"left":0.109375,"top":0.8354167,"width":0.125,"height":0.016666668},"value":"Enter a prompt for Gemini\nencrypted","help_text":"","role_description":"text entry area","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXStaticText","text":"Enter a prompt for Gemini","depth":21,"bounds":{"left":0.1171875,"top":0.8354167,"width":0.08203125,"height":0.016666668},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"encrypted","depth":21,"bounds":{"left":0.10820313,"top":0.8354167,"width":0.0078125,"height":0.016666668},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Open upload file menu","depth":20,"bounds":{"left":0.1046875,"top":0.86527777,"width":0.015625,"height":0.027777778},"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tools","depth":18,"bounds":{"left":0.1234375,"top":0.86527777,"width":0.015625,"height":0.027777778},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open mode picker","depth":20,"bounds":{"left":0.190625,"top":0.8645833,"width":0.03046875,"height":0.027777778},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pro","depth":23,"bounds":{"left":0.196875,"top":0.87222224,"width":0.00859375,"height":0.013194445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Stop response","depth":19,"bounds":{"left":0.22265625,"top":0.86388886,"width":0.01640625,"height":0.029166667},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Your Jiminny chats aren’t used to improve our models. Gemini is AI and can make mistakes, including about people.","depth":17,"bounds":{"left":0.10039063,"top":0.9097222,"width":0.14296874,"height":0.022222223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Your privacy & Gemini Opens in a new window","depth":17,"bounds":{"left":0.1484375,"top":0.93194443,"width":0.046875,"height":0.011111111},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Your privacy & Gemini","depth":18,"bounds":{"left":0.1484375,"top":0.93194443,"width":0.046875,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Opens in a new window","depth":19,"bounds":{"left":0.09335937,"top":0.93125,"width":0.05078125,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Gemini is typing","depth":9,"bounds":{"left":0.09335937,"top":0.07986111,"width":0.0421875,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Summarize page","depth":7,"bounds":{"left":0.1,"top":0.96319443,"width":0.06289063,"height":0.022222223},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Summarize page","depth":9,"bounds":{"left":0.10664062,"top":0.9673611,"width":0.049609374,"height":0.013888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"install","depth":11,"bounds":{"left":0.23125,"top":0.9451389,"width":0.01875,"height":0.030555556},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"My Recordings","depth":10,"bounds":{"left":0.25703126,"top":0.10277778,"width":0.043359376,"height":0.045833334},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"My Recordings","depth":11,"bounds":{"left":0.2609375,"top":0.12013889,"width":0.035546876,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Everyone's Recordings","depth":10,"bounds":{"left":0.39648438,"top":0.10277778,"width":0.061328124,"height":0.045833334},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Everyone's Recordings","depth":11,"bounds":{"left":0.40039062,"top":0.12013889,"width":0.053515624,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"No Recordings","depth":13,"bounds":{"left":0.33945313,"top":0.23125,"width":0.0359375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Schedule","depth":9,"bounds":{"left":0.2589844,"top":0.2888889,"width":0.028515626,"height":0.023611112},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Schedule","depth":10,"bounds":{"left":0.2589844,"top":0.29305556,"width":0.028515626,"height":0.015277778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Invite Notetaker","depth":10,"bounds":{"left":0.40976563,"top":0.28680557,"width":0.051953126,"height":0.025},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXComboBox","text":"This Week","depth":10,"bounds":{"left":0.2589844,"top":0.3298611,"width":0.196875,"height":0.025694445},"value":"This Week","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"This Week","depth":12,"bounds":{"left":0.26328126,"top":0.33680555,"width":0.02578125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXComboBox","text":"My Schedule","depth":10,"bounds":{"left":0.2589844,"top":0.3625,"width":0.196875,"height":0.025694445},"value":"My Schedule","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"My Schedule","depth":12,"bounds":{"left":0.26328126,"top":0.36944443,"width":0.03125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"No Meetings","depth":12,"bounds":{"left":0.34179688,"top":0.48055556,"width":0.03125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Trending this month","depth":9,"bounds":{"left":0.26289064,"top":0.56319445,"width":0.054296874,"height":0.016666668},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Trending this month","depth":10,"bounds":{"left":0.26289064,"top":0.56458336,"width":0.054296874,"height":0.013888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXComboBox","text":"Sort by Most played","depth":9,"bounds":{"left":0.3921875,"top":0.55833334,"width":0.059765626,"height":0.025694445},"value":"Sort by Most played","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Sort by","depth":10,"help_text":"","role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Most played","depth":11,"bounds":{"left":0.39648438,"top":0.56527776,"width":0.0296875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"No Recordings","depth":12,"bounds":{"left":0.33945313,"top":0.68333334,"width":0.0359375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Live Feed","depth":9,"bounds":{"left":0.26289064,"top":0.7590278,"width":0.02578125,"height":0.016666668},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Live Feed","depth":10,"bounds":{"left":0.26289064,"top":0.7604167,"width":0.02578125,"height":0.013888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"No Activity","depth":12,"bounds":{"left":0.34375,"top":0.87222224,"width":0.02734375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Clear the Web Console output (⌘K, Ctrl+L)","depth":15,"bounds":{"left":0.46328124,"top":0.068055555,"width":0.01015625,"height":0.013888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXTextField","text":"Filter Output","depth":15,"bounds":{"left":0.47578126,"top":0.065972224,"width":0.36132812,"height":0.018055556},"role_description":"search text field","subrole":"AXSearchField","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Errors","depth":15,"bounds":{"left":0.8417969,"top":0.068055555,"width":0.0171875,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Warnings","depth":15,"bounds":{"left":0.85976565,"top":0.068055555,"width":0.023828125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Info","depth":15,"bounds":{"left":0.884375,"top":0.068055555,"width":0.0125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Logs","depth":15,"bounds":{"left":0.89765626,"top":0.068055555,"width":0.014453125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Debug","depth":15,"bounds":{"left":0.9128906,"top":0.068055555,"width":0.01796875,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"CSS","depth":15,"bounds":{"left":0.9359375,"top":0.068055555,"width":0.01328125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"XHR","depth":15,"bounds":{"left":0.95,"top":0.068055555,"width":0.013671875,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Requests","depth":15,"bounds":{"left":0.9644531,"top":0.068055555,"width":0.023828125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Console Settings","depth":15,"bounds":{"left":0.9898437,"top":0.06527778,"width":0.01015625,"height":0.019444445},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.08680555,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.0875,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com/dashboard","depth":20,"bounds":{"left":0.484375,"top":0.0875,"width":0.4519531,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com/dashboard","depth":21,"bounds":{"left":0.484375,"top":0.0875,"width":0.095703125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/1.1","depth":21,"bounds":{"left":0.9386719,"top":0.0875,"width":0.02578125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9652344,"top":0.0875,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.97382814,"top":0.0875,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.4703125,"top":0.10138889,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Content-Security-Policy warnings","depth":20,"bounds":{"left":0.4765625,"top":0.10208333,"width":0.0828125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"2","depth":21,"bounds":{"left":0.5660156,"top":0.103472225,"width":0.00234375,"height":0.007638889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.11597222,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.11666667,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;1,400;1,700&family=Roboto:wght@500&display=swap","depth":20,"bounds":{"left":0.484375,"top":0.11666667,"width":0.4519531,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;1,400;1,700&family=Roboto:wght@500&display=swap","depth":21,"bounds":{"left":0.484375,"top":0.11666667,"width":0.29726562,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/2","depth":21,"bounds":{"left":0.9386719,"top":0.11666667,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.96015626,"top":0.11666667,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"OK 0ms]","depth":21,"bounds":{"left":0.96875,"top":0.11666667,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at","depth":21,"bounds":{"left":0.471875,"top":0.13125,"width":0.30507812,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAUi-qNiXg7eU0.woff2","depth":21,"bounds":{"left":0.7769531,"top":0.13125,"width":0.18359375,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAUi-qNiXg7eU0.woff2","depth":22,"bounds":{"left":0.7769531,"top":0.13125,"width":0.18359375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"because it violates the following directive: “font-src 'self'","depth":21,"bounds":{"left":0.471875,"top":0.13125,"width":0.4910156,"height":0.019444445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com","depth":21,"bounds":{"left":0.63203126,"top":0.14097223,"width":0.06992187,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com","depth":22,"bounds":{"left":0.63203126,"top":0.14097223,"width":0.06992187,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com/","depth":21,"bounds":{"left":0.7042969,"top":0.14097223,"width":0.07265625,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com/","depth":22,"bounds":{"left":0.7042969,"top":0.14097223,"width":0.07265625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://js.intercomcdn.com","depth":21,"bounds":{"left":0.7792969,"top":0.14097223,"width":0.0671875,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://js.intercomcdn.com","depth":22,"bounds":{"left":0.7792969,"top":0.14097223,"width":0.0671875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"”","depth":21,"bounds":{"left":0.84648436,"top":0.14097223,"width":0.002734375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"dashboard","depth":20,"bounds":{"left":0.96601564,"top":0.13125,"width":0.0234375,"height":0.009722223},"help_text":"View source in Debugger → https://app.dev.jiminny.com/dashboard","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"dashboard","depth":22,"bounds":{"left":0.96601564,"top":0.13125,"width":0.0234375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at","depth":21,"bounds":{"left":0.471875,"top":0.15555556,"width":0.30507812,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAXC-qNiXg7Q.woff2","depth":21,"bounds":{"left":0.7769531,"top":0.15555556,"width":0.178125,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAXC-qNiXg7Q.woff2","depth":22,"bounds":{"left":0.7769531,"top":0.15555556,"width":0.178125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"because it violates the following directive: “font-src 'self'","depth":21,"bounds":{"left":0.471875,"top":0.15555556,"width":0.4859375,"height":0.019444445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com","depth":21,"bounds":{"left":0.63203126,"top":0.16527778,"width":0.06992187,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com","depth":22,"bounds":{"left":0.63203126,"top":0.16527778,"width":0.06992187,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com/","depth":21,"bounds":{"left":0.7042969,"top":0.16527778,"width":0.07265625,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com/","depth":22,"bounds":{"left":0.7042969,"top":0.16527778,"width":0.07265625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://js.intercomcdn.com","depth":21,"bounds":{"left":0.7792969,"top":0.16527778,"width":0.0671875,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://js.intercomcdn.com","depth":22,"bounds":{"left":0.7792969,"top":0.16527778,"width":0.0671875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"”","depth":21,"bounds":{"left":0.84648436,"top":0.16527778,"width":0.002734375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"dashboard","depth":20,"bounds":{"left":0.96601564,"top":0.15555556,"width":0.0234375,"height":0.009722223},"help_text":"View source in Debugger → https://app.dev.jiminny.com/dashboard","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"dashboard","depth":22,"bounds":{"left":0.96601564,"top":0.15555556,"width":0.0234375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at","depth":21,"bounds":{"left":0.471875,"top":0.17986111,"width":0.30507812,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_FQftx9897sxZ.woff2","depth":21,"bounds":{"left":0.471875,"top":0.17986111,"width":0.4910156,"height":0.019444445},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_FQftx9897sxZ.woff2","depth":22,"bounds":{"left":0.471875,"top":0.17986111,"width":0.4910156,"height":0.019444445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"because it violates the following directive: “font-src 'self'","depth":21,"bounds":{"left":0.4796875,"top":0.18958333,"width":0.16289063,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com","depth":21,"bounds":{"left":0.6425781,"top":0.18958333,"width":0.06953125,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com","depth":22,"bounds":{"left":0.6425781,"top":0.18958333,"width":0.06953125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com/","depth":21,"bounds":{"left":0.71484375,"top":0.18958333,"width":0.072265625,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com/","depth":22,"bounds":{"left":0.71484375,"top":0.18958333,"width":0.072265625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://js.intercomcdn.com","depth":21,"bounds":{"left":0.78984374,"top":0.18958333,"width":0.0671875,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://js.intercomcdn.com","depth":22,"bounds":{"left":0.78984374,"top":0.18958333,"width":0.0671875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"”","depth":21,"bounds":{"left":0.8570312,"top":0.18958333,"width":0.00234375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"dashboard","depth":20,"bounds":{"left":0.96601564,"top":0.17986111,"width":0.0234375,"height":0.009722223},"help_text":"View source in Debugger → https://app.dev.jiminny.com/dashboard","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"dashboard","depth":22,"bounds":{"left":0.96601564,"top":0.17986111,"width":0.0234375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at","depth":21,"bounds":{"left":0.471875,"top":0.20416667,"width":0.30507812,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_Gwftx9897g.woff2","depth":21,"bounds":{"left":0.471875,"top":0.20416667,"width":0.4910156,"height":0.019444445},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_Gwftx9897g.woff2","depth":22,"bounds":{"left":0.471875,"top":0.20416667,"width":0.4910156,"height":0.019444445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"because it violates the following directive: “font-src 'self'","depth":21,"bounds":{"left":0.47460938,"top":0.21388888,"width":0.1625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com","depth":21,"bounds":{"left":0.6371094,"top":0.21388888,"width":0.06992187,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com","depth":22,"bounds":{"left":0.6371094,"top":0.21388888,"width":0.06992187,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com/","depth":21,"bounds":{"left":0.7097656,"top":0.21388888,"width":0.072265625,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com/","depth":22,"bounds":{"left":0.7097656,"top":0.21388888,"width":0.072265625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://js.intercomcdn.com","depth":21,"bounds":{"left":0.7847656,"top":0.21388888,"width":0.0671875,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://js.intercomcdn.com","depth":22,"bounds":{"left":0.7847656,"top":0.21388888,"width":0.0671875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"”","depth":21,"bounds":{"left":0.85195315,"top":0.21388888,"width":0.00234375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"dashboard","depth":20,"bounds":{"left":0.96601564,"top":0.20416667,"width":0.0234375,"height":0.009722223},"help_text":"View source in Debugger → https://app.dev.jiminny.com/dashboard","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-3520186457033888925
|
4293020297422048420
|
visual_change
|
accessibility
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Close tab
New Tab
Customize sidebar
Close Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
AI Chat settings
Close
WORK, Google Account: [EMAIL]
Main menu
New chat
Gemini
PRO
PRO
Conversation with Gemini
Conversation with Gemini
Copy prompt
Edit
You said firefox how to preseve console
You said
firefox how to preseve console
Show more options
Enter a prompt for Gemini
encrypted
Enter a prompt for Gemini
encrypted
Open upload file menu
Tools
Open mode picker
Pro
Stop response
Your Jiminny chats aren’t used to improve our models. Gemini is AI and can make mistakes, including about people.
Your privacy & Gemini Opens in a new window
Your privacy & Gemini
Opens in a new window
Gemini is typing
Summarize page
Summarize page
install
My Recordings
My Recordings
Everyone's Recordings
Everyone's Recordings
No Recordings
Schedule
Schedule
Invite Notetaker
This Week
This Week
My Schedule
My Schedule
No Meetings
Trending this month
Trending this month
Sort by Most played
Sort by
Most played
No Recordings
Live Feed
Live Feed
No Activity
Clear the Web Console output (⌘K, Ctrl+L)
Filter Output
Errors
Warnings
Info
Logs
Debug
CSS
XHR
Requests
Console Settings
Show/hide message details.
GET
[URL_WITH_CREDENTIALS]
[URL_WITH_CREDENTIALS]
[HTTP/2
200
OK 0ms]
Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at
https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAUi-qNiXg7eU0.woff2
https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAUi-qNiXg7eU0.woff2
because it violates the following directive: “font-src 'self'
https://app.dev.jiminny.com
https://app.dev.jiminny.com
https://app.dev.jiminny.com/
https://app.dev.jiminny.com/
https://js.intercomcdn.com
https://js.intercomcdn.com
”
dashboard
dashboard
Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at
https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAXC-qNiXg7Q.woff2
https://fonts.gstatic.com/s/lato/v25/S6u8w4BMUTPHjxsAXC-qNiXg7Q.woff2
because it violates the following directive: “font-src 'self'
https://app.dev.jiminny.com
https://app.dev.jiminny.com
https://app.dev.jiminny.com/
https://app.dev.jiminny.com/
https://js.intercomcdn.com
https://js.intercomcdn.com
”
dashboard
dashboard
Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at
https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_FQftx9897sxZ.woff2
https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_FQftx9897sxZ.woff2
because it violates the following directive: “font-src 'self'
https://app.dev.jiminny.com
https://app.dev.jiminny.com
https://app.dev.jiminny.com/
https://app.dev.jiminny.com/
https://js.intercomcdn.com
https://js.intercomcdn.com
”
dashboard
dashboard
Content-Security-Policy: (Report-Only policy) The page’s settings would block the loading of a resource (font-src) at
https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_Gwftx9897g.woff2
https://fonts.gstatic.com/s/lato/v25/S6u_w4BMUTPHjxsI5wq_Gwftx9897g.woff2
because it violates the following directive: “font-src 'self'
https://app.dev.jiminny.com
https://app.dev.jiminny.com
https://app.dev.jiminny.com/
https://app.dev.jiminny.com/
https://js.intercomcdn.com
https://js.intercomcdn.com
”
dashboard...
|
46932
|
|
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
|
|
46970
|
NULL
|
0
|
2026-04-17T10:58:19.608434+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423499608_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>...
|
[{"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}]...
|
8951690351009007186
|
-8754547338898009690
|
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>...
|
NULL
|
|
47061
|
NULL
|
0
|
2026-04-17T11:03:21.057156+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423801057_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelplab|Support Daily - in 57 m100% <47APP (-zsh)X4-zshDOCKER-881DEV (docker)O ₴2APP (-zsh)X3-zsh./public/vue-assets/assets/ondemand-DkNR1-pf.js:./public/vue-assets/assets/CrmLink-DKYsnHnx.js./public/vue-assets/assets/liquor-tree-COUefof4.js./public/vue-assets/assets/DealRiskList-BWvQyROv.js:./public/vue-assets/assets/AskAnything-BNRpAA8H.js:./public/vue-assets/assets/lib-CwM9toD2.js./public/vue-assets/assets/AppFormField-Cd83royC.js./public/vue-assets/assets/deal-view-BHTz2Ksy.js:./public/vue-assets/assets/exports-D1lmea40.js../public/vue-assets/assets/playlists-5wFR1ij2.js../public/vue-assets/assets/callScoringTemplates-zeRn40ul.js../public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js./public/vue-assets/assets/onboard-D1qld9L0.js./public/vue-assets/assets/StatusBadge-CbqQ5gnA.js./public/vue-assets/assets/kiosk-DSF1ebGq.js./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-LnukdLUQ.js../public/vue-assets/assets/ListView-Bcd0qibH.js:./public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js./public/vue-assets/assets/WelcomeLayout-B6wd32HG.js../public/vue-assets/assets/dashboard-C4k4MPim.js:./public/vue-assets/assets/emoji-input-CSq87OVy.js../public/vue-assets/assets/AppButton-D3qMdODr.js../public/vue-assets/assets/sentry-BQx81U9A.js:./public/vue-assets/assets/OrgSettingsLayout-BQgZ11_y.js./public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js./public/vue-assets/assets/playback-D_95E_To.js./public/vue-assets/assets/index.module-Bjlhgfdl.js./public/vue-assets/assets/intl-tel-input-BW4mv40Q.js../public/vue-assets/assets/team-insights-NuK2ryxe.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js./public/vue-assets/assets/live-C1SbBwo3.js../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-CAouXZsY.js:./public/vue-assets/assets/logged-in-layout-ehXyHVjH.js• ₴526.88kB27.91kB30.75kB34.39kB39.50kB39.69kB41.91kB43.22kB47.84kB48.28kB55.13kB61.28kB62.98kB63.11kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59 kB120.67kB128.71kB129.28 kB133.44kB164.28kB176.33kB180.40kB198.79kB218.14kB264.94 kВ298.57kB307.13kB343.99kB367.43kB689.63kB825.23 kB1,402.70 kB[plugin builtin:vite-reporter](!) Some chunks are larger than 500 kB after minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldown0ptions.output.codeSplitting to improve chunking: [URL_WITH_CREDENTIALS] 88APP...
|
NULL
|
-3177134468110149169
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelplab|Support Daily - in 57 m100% <47APP (-zsh)X4-zshDOCKER-881DEV (docker)O ₴2APP (-zsh)X3-zsh./public/vue-assets/assets/ondemand-DkNR1-pf.js:./public/vue-assets/assets/CrmLink-DKYsnHnx.js./public/vue-assets/assets/liquor-tree-COUefof4.js./public/vue-assets/assets/DealRiskList-BWvQyROv.js:./public/vue-assets/assets/AskAnything-BNRpAA8H.js:./public/vue-assets/assets/lib-CwM9toD2.js./public/vue-assets/assets/AppFormField-Cd83royC.js./public/vue-assets/assets/deal-view-BHTz2Ksy.js:./public/vue-assets/assets/exports-D1lmea40.js../public/vue-assets/assets/playlists-5wFR1ij2.js../public/vue-assets/assets/callScoringTemplates-zeRn40ul.js../public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js./public/vue-assets/assets/onboard-D1qld9L0.js./public/vue-assets/assets/StatusBadge-CbqQ5gnA.js./public/vue-assets/assets/kiosk-DSF1ebGq.js./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-LnukdLUQ.js../public/vue-assets/assets/ListView-Bcd0qibH.js:./public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js./public/vue-assets/assets/WelcomeLayout-B6wd32HG.js../public/vue-assets/assets/dashboard-C4k4MPim.js:./public/vue-assets/assets/emoji-input-CSq87OVy.js../public/vue-assets/assets/AppButton-D3qMdODr.js../public/vue-assets/assets/sentry-BQx81U9A.js:./public/vue-assets/assets/OrgSettingsLayout-BQgZ11_y.js./public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js./public/vue-assets/assets/playback-D_95E_To.js./public/vue-assets/assets/index.module-Bjlhgfdl.js./public/vue-assets/assets/intl-tel-input-BW4mv40Q.js../public/vue-assets/assets/team-insights-NuK2ryxe.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js./public/vue-assets/assets/live-C1SbBwo3.js../public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-CAouXZsY.js:./public/vue-assets/assets/logged-in-layout-ehXyHVjH.js• ₴526.88kB27.91kB30.75kB34.39kB39.50kB39.69kB41.91kB43.22kB47.84kB48.28kB55.13kB61.28kB62.98kB63.11kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59 kB120.67kB128.71kB129.28 kB133.44kB164.28kB176.33kB180.40kB198.79kB218.14kB264.94 kВ298.57kB307.13kB343.99kB367.43kB689.63kB825.23 kB1,402.70 kB[plugin builtin:vite-reporter](!) Some chunks are larger than 500 kB after minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldown0ptions.output.codeSplitting to improve chunking: [URL_WITH_CREDENTIALS] 88APP...
|
NULL
|
|
47062
|
NULL
|
0
|
2026-04-17T11:03:21.026440+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776423801026_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileFditViewNavigateCodelaravelRefactonToo PhpStormFileFditViewNavigateCodelaravelRefactonToolsWindowHelpFV faVsco.s vT° JY-20692-fix-integration-app-[API_KEY] v>W testsU connect.lessV connect.vuedashboardW DeallnsightsD errorPages_ export-portalextension-installledinvitationvonconterenceMlavoutD LiveCoach› Locked>D login>D MeetingConsent_ mobilel onboardl•__mocks_•U_tests_V MobileAppDownk1 Onboard.lessV Onboard vueTs useProvidersSyncondemandplaybackD playlistsW Settingsshared_ SoftphoneCoach• Usegicons_leaminsightscomposaoes> M7 directives> M7 helpers> → locales>_ pluginsrouter• storeD utilsus main.jsTs types.a.tsJstypes.jsI Vite= owsersistE env defaultC AutomatedReportsService.phpC) SendReportJob.phpC SendReportMailJob.php= custom.loc= aravel.logc SF liminny@localhostU scratch_1,jsonconnect.vueV Onboard.vue XC ReportController.phpTokenBuilder.phpc leamsetuocontroller.onppnp apl.ono< Hs local liminnyalocalnost4 console [EUlC CrmEntityRepository.pho(iii) crm configurations [EU]© Filesystem.php© Team.phpC RequestGenerateReportJob.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.phpOpportunitySyncTrait.phpC Opportunity.phpT InteractsWithPivotTable.phgOpoorunityupdaled.onoOpoonunitystadeupaated.onoc) FventService?rovider.onm(c OnnortunitvPendindAiAnalvsisatterstadechanded.omC RunOpportunityAiAnalysis.phpC ProcessAlAutomationAnalysiskesults.onv© ImportOpportunityBatch.php¿ console IPRODIIc console [STAGING]<script>379setup() (const crmconnectintegrationApp = async function O 1477const integrationApp = new IntegrationAppClientetoken: crmToken.value.V1V9 AVTImportBatchJobTrait.php(C) Service.phpcachedStagesCcW1018trait upportunitysynctraitA33 V2 V19private function resolverorecastcategory(?string pforecastCategory): stringi.• 5062 usagesprivate tunecion importexternalele uabatatarray epropertzes, zht sopportunity ru*509const connection = awalt integrationApp. integration(crmbetalls.name)ovenvevronnectlonssnowPoweredby: talse,aLLowulrloleconnectons. tauseF):1050§507510console.Log(' [IntegrationAn&openNewConnection resolved:', JSON.stringify(connection)):$crmFields = $this->getOpportunitySyncableFields:Sthis->inportOpportunitvCrmFieldDataSproperties. ScrmFields.lSOOOONUILE103310341035104810491056105111054Lusaees/**private function importupportunatycontacts (Opportunaty sopportunity, array sas:516* Sync opportunity contacts using differentzal approach* Inis compares current vs new associacions and only makes necessary changes=513=5171518519if (!connection | connection.disconnected === true | connection.connected === false) ishowsnackbarErrord"A connection with your cRM could not be established".);returnhtry 1consr saverequest = valt axos.oos col vnccoraron-doo-connecr1076107110771078107910861081108410841093SZ11 usage522private tunccion syncupporcunttyconcactsultterenclat upporcunicy gopportunity.523524525private function getCurrentContactCrmIds(Opportunity Sopportunity): array.....526— 52752815291550if (saveRequest.data &x saveRequest.data.success === true) ‹** If all is good refresh the page here *return location.reloadhlusageprivate function logcontactAssoclationchangesOpportunity sopportunityarray scurrenccontacuermias,array scontaccassoclatlons.array scontacusioAdd,array +concacustokemove0: void {...}chrow new trror(saverequest.daca.message):f catch (error) {console.looterror).snowonackoarerrorunoriauzerrror error.054=533534548=5495501const preparelntegrationAppConnection = async function O k...rAif (crmRequired) 1Prepare the crm token only if the CRM is requiredLocal CnandesShelfLogconsoeLog xChanges 10 files=.env.local app(© ActivityController.php app/Http/Controllers/APlV connect.vue front-end/src/components/connect(C JiminnyDebugCommand.php app/Console/Commandsphp logging.php contigV Onboard.vue front-end/src/components/onboard© Playbackservice.php app/ServicesC ReportController.php app/Http/Controllers/Webhook(C) SyncTolntercom.ohp app/.Jobs/Teampnp weo.ono rouresUnversioned Files 8 filesslae-by-side viewerDo not ignore -Mionlleht worasmboocterc_tront-enc/src/comoonents/onooard/onboard.vueshowrowereoby: tauseaLLoyuuroleconneccons. tause.7);if (iconnection connection.disconnected === truelshowSnackbarError"A connection with your CRM could not be established".):return;• Support Daily • in 57 mA100% 145Fri 17 Apr 14:03:20AutomatedRenortsCommandTestCascadeAutomated Report RetDebugging OpportunitUpdate Connection Lcconnect.vue tront-end/src/components/connecnothing is there"cant see log'sThe console is full of CSP warnings and errors but none of your [IntegrationApp] logs. This means intedrat1onApponcL1ck Is never belno called at all — the burton click isn't reachine that tunction.Check1. Is the button actually calling integrationApponclick? - the click handler might be bound to adifferent method or the component re-rendered2. Is the frontend bundle rebuilt? - you may be running a stale build that doesn't include your changesSearched.intecrationAoo@nClickin tront-enc/src/ccents/connect/connect.vue (2)The binding looks correct. The most likely issue is a stale build — your changes aren't compiled yet. Add aquick test: put a console.log before the IntegrationAppClient instantiation that's impossible to miss:Reao connectvue :158-|44connect.vueNow after saving, hard refresh the browser ( Cmd+Shift+R ) to bust the cache. If you still don't see (Integratlonado incecrac1onApouncllck called arter cllckine tne outton, tne dev server nasn't olckea up tnechanges or the click event isn't reaching this component (e.g. another element is intercepting it).WAsk anything (884L)+ <> Code Claude Sonnet 4.62 differencescurrent versionallowMultipleConnections: false,console.log'lIntegrationAppl openNewConnection resolved:', JSON.stringify(connection)):if (iconnection connection.disconnected === true connection.connected === false) <showSnackbarError("A connection with your CRM could not be established",return;W Windsurf Teams 510:95 V UTF-8f 2 spaces...
|
NULL
|
6534610863620137170
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileFditViewNavigateCodelaravelRefactonToo PhpStormFileFditViewNavigateCodelaravelRefactonToolsWindowHelpFV faVsco.s vT° JY-20692-fix-integration-app-[API_KEY] v>W testsU connect.lessV connect.vuedashboardW DeallnsightsD errorPages_ export-portalextension-installledinvitationvonconterenceMlavoutD LiveCoach› Locked>D login>D MeetingConsent_ mobilel onboardl•__mocks_•U_tests_V MobileAppDownk1 Onboard.lessV Onboard vueTs useProvidersSyncondemandplaybackD playlistsW Settingsshared_ SoftphoneCoach• Usegicons_leaminsightscomposaoes> M7 directives> M7 helpers> → locales>_ pluginsrouter• storeD utilsus main.jsTs types.a.tsJstypes.jsI Vite= owsersistE env defaultC AutomatedReportsService.phpC) SendReportJob.phpC SendReportMailJob.php= custom.loc= aravel.logc SF liminny@localhostU scratch_1,jsonconnect.vueV Onboard.vue XC ReportController.phpTokenBuilder.phpc leamsetuocontroller.onppnp apl.ono< Hs local liminnyalocalnost4 console [EUlC CrmEntityRepository.pho(iii) crm configurations [EU]© Filesystem.php© Team.phpC RequestGenerateReportJob.php© CreateHeldActivityEvent.php© TrackProviderInstalledEvent.phpOpportunitySyncTrait.phpC Opportunity.phpT InteractsWithPivotTable.phgOpoorunityupdaled.onoOpoonunitystadeupaated.onoc) FventService?rovider.onm(c OnnortunitvPendindAiAnalvsisatterstadechanded.omC RunOpportunityAiAnalysis.phpC ProcessAlAutomationAnalysiskesults.onv© ImportOpportunityBatch.php¿ console IPRODIIc console [STAGING]<script>379setup() (const crmconnectintegrationApp = async function O 1477const integrationApp = new IntegrationAppClientetoken: crmToken.value.V1V9 AVTImportBatchJobTrait.php(C) Service.phpcachedStagesCcW1018trait upportunitysynctraitA33 V2 V19private function resolverorecastcategory(?string pforecastCategory): stringi.• 5062 usagesprivate tunecion importexternalele uabatatarray epropertzes, zht sopportunity ru*509const connection = awalt integrationApp. integration(crmbetalls.name)ovenvevronnectlonssnowPoweredby: talse,aLLowulrloleconnectons. tauseF):1050§507510console.Log(' [IntegrationAn&openNewConnection resolved:', JSON.stringify(connection)):$crmFields = $this->getOpportunitySyncableFields:Sthis->inportOpportunitvCrmFieldDataSproperties. ScrmFields.lSOOOONUILE103310341035104810491056105111054Lusaees/**private function importupportunatycontacts (Opportunaty sopportunity, array sas:516* Sync opportunity contacts using differentzal approach* Inis compares current vs new associacions and only makes necessary changes=513=5171518519if (!connection | connection.disconnected === true | connection.connected === false) ishowsnackbarErrord"A connection with your cRM could not be established".);returnhtry 1consr saverequest = valt axos.oos col vnccoraron-doo-connecr1076107110771078107910861081108410841093SZ11 usage522private tunccion syncupporcunttyconcactsultterenclat upporcunicy gopportunity.523524525private function getCurrentContactCrmIds(Opportunity Sopportunity): array.....526— 52752815291550if (saveRequest.data &x saveRequest.data.success === true) ‹** If all is good refresh the page here *return location.reloadhlusageprivate function logcontactAssoclationchangesOpportunity sopportunityarray scurrenccontacuermias,array scontaccassoclatlons.array scontacusioAdd,array +concacustokemove0: void {...}chrow new trror(saverequest.daca.message):f catch (error) {console.looterror).snowonackoarerrorunoriauzerrror error.054=533534548=5495501const preparelntegrationAppConnection = async function O k...rAif (crmRequired) 1Prepare the crm token only if the CRM is requiredLocal CnandesShelfLogconsoeLog xChanges 10 files=.env.local app(© ActivityController.php app/Http/Controllers/APlV connect.vue front-end/src/components/connect(C JiminnyDebugCommand.php app/Console/Commandsphp logging.php contigV Onboard.vue front-end/src/components/onboard© Playbackservice.php app/ServicesC ReportController.php app/Http/Controllers/Webhook(C) SyncTolntercom.ohp app/.Jobs/Teampnp weo.ono rouresUnversioned Files 8 filesslae-by-side viewerDo not ignore -Mionlleht worasmboocterc_tront-enc/src/comoonents/onooard/onboard.vueshowrowereoby: tauseaLLoyuuroleconneccons. tause.7);if (iconnection connection.disconnected === truelshowSnackbarError"A connection with your CRM could not be established".):return;• Support Daily • in 57 mA100% 145Fri 17 Apr 14:03:20AutomatedRenortsCommandTestCascadeAutomated Report RetDebugging OpportunitUpdate Connection Lcconnect.vue tront-end/src/components/connecnothing is there"cant see log'sThe console is full of CSP warnings and errors but none of your [IntegrationApp] logs. This means intedrat1onApponcL1ck Is never belno called at all — the burton click isn't reachine that tunction.Check1. Is the button actually calling integrationApponclick? - the click handler might be bound to adifferent method or the component re-rendered2. Is the frontend bundle rebuilt? - you may be running a stale build that doesn't include your changesSearched.intecrationAoo@nClickin tront-enc/src/ccents/connect/connect.vue (2)The binding looks correct. The most likely issue is a stale build — your changes aren't compiled yet. Add aquick test: put a console.log before the IntegrationAppClient instantiation that's impossible to miss:Reao connectvue :158-|44connect.vueNow after saving, hard refresh the browser ( Cmd+Shift+R ) to bust the cache. If you still don't see (Integratlonado incecrac1onApouncllck called arter cllckine tne outton, tne dev server nasn't olckea up tnechanges or the click event isn't reaching this component (e.g. another element is intercepting it).WAsk anything (884L)+ <> Code Claude Sonnet 4.62 differencescurrent versionallowMultipleConnections: false,console.log'lIntegrationAppl openNewConnection resolved:', JSON.stringify(connection)):if (iconnection connection.disconnected === true connection.connected === false) <showSnackbarError("A connection with your CRM could not be established",return;W Windsurf Teams 510:95 V UTF-8f 2 spaces...
|
NULL
|
|
47109
|
NULL
|
0
|
2026-04-17T11:08:41.576899+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776424121576_m1.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelpla6]Support Daily - in 52 m100% <47-zshAPP (-zsh)|X4-zshDOCKER• ₴1DEV (docker)O ₴2APP (-zsh)X3./public/vue-assets/assets/ondemand-DutwAJ6x.js:./public/vue-assets/assets/CrmLink-DKYsnHnx.js./public/vue-assets/assets/liquor-tree-COUefof4.js./public/vue-assets/assets/DealRiskList-CjPoT6NF.js:./public/vue-assets/assets/AskAnything-a4BpUaF5.js:./public/vue-assets/assets/lib-CwM9toD2.js./public/vue-assets/assets/AppFormField-CtfldUq7.js:./public/vue-assets/assets/deal-view-DhouIWLw.js../public/vue-assets/assets/exports-D1lmea40.js../public/vue-assets/assets/playlists-BWns-By9.js../public/vue-assets/assets/callScoringTemplates-zeRn40ul.js../public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js./public/vue-assets/assets/onboard-BENebgvU.js./public/vue-assets/assets/StatusBadge-CmcY3nfX.js./public/vue-assets/assets/kiosk-1vhk4_89.js./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-DN-kVyxK.js../public/vue-assets/assets/ListView-POU6dSu7.js:./public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js./public/vue-assets/assets/WelcomeLayout-B6wd32HG.js../public/vue-assets/assets/dashboard-DYH5MiIH.js:./public/vue-assets/assets/emoji-input-CSq87OVy.js../public/vue-assets/assets/AppButton-D3qMd0Dr.js../public/vue-assets/assets/sentry-c0Rhilsu.js:./public/vue-assets/assets/OrgSettingsLayout-ByQjX4wG.js./public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js./public/vue-assets/assets/playback-BH9MB7YC.js./public/vue-assets/assets/index.module-Bjlhgfdl.js./public/vue-assets/assets/intl-tel-input-BW4mv40Q.js./public/vue-assets/assets/team-insights-BqCZQtVc.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js./public/vue-assets/assets/live-XL9ZmWsU.js./public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-CPycMtZF.js../public/vue-assets/assets/logged-in-layout-B_JDhk5y.js• ₴526.88kB27.91kB30.75kB34.39kB39.50kB39.69kB41.91kB43.22kB47.84kB48.28kB55.13kB61.28kB62.98kB63.26kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59 kВ120.67kB128.71 kB129.28 kB133.44 kB164.28kB176.33kB180.40kB198.79kB218.14kB264.94 kВ298.57kB307.13kB343.99kB367.43 kB689.63kB825.23 kB1,402.70 kB• built in 28.48s[plugin builtin:vite-reporter](!) Some chunksare larger than 500 kB after minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldown0ptions.output.codeSplitting to improve chunking: [URL_WITH_CREDENTIALS] 88APP...
|
NULL
|
2213734560777715800
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2ShellEditViewSessionScriptsProfilesWindowHel iTerm2ShellEditViewSessionScriptsProfilesWindowHelpla6]Support Daily - in 52 m100% <47-zshAPP (-zsh)|X4-zshDOCKER• ₴1DEV (docker)O ₴2APP (-zsh)X3./public/vue-assets/assets/ondemand-DutwAJ6x.js:./public/vue-assets/assets/CrmLink-DKYsnHnx.js./public/vue-assets/assets/liquor-tree-COUefof4.js./public/vue-assets/assets/DealRiskList-CjPoT6NF.js:./public/vue-assets/assets/AskAnything-a4BpUaF5.js:./public/vue-assets/assets/lib-CwM9toD2.js./public/vue-assets/assets/AppFormField-CtfldUq7.js:./public/vue-assets/assets/deal-view-DhouIWLw.js../public/vue-assets/assets/exports-D1lmea40.js../public/vue-assets/assets/playlists-BWns-By9.js../public/vue-assets/assets/callScoringTemplates-zeRn40ul.js../public/vue-assets/assets/_copy0bject-USkOnlaQ.js../public/vue-assets/assets/pusher-znYCfz7U.js./public/vue-assets/assets/onboard-BENebgvU.js./public/vue-assets/assets/StatusBadge-CmcY3nfX.js./public/vue-assets/assets/kiosk-1vhk4_89.js./public/vue-assets/assets/preload-helper-DCvhahzG.js../public/vue-assets/assets/deal-insights-DN-kVyxK.js../public/vue-assets/assets/ListView-POU6dSu7.js:./public/vue-assets/assets/_plugin-vue_export-helper-DD3s5456.js./public/vue-assets/assets/WelcomeLayout-B6wd32HG.js../public/vue-assets/assets/dashboard-DYH5MiIH.js:./public/vue-assets/assets/emoji-input-CSq87OVy.js../public/vue-assets/assets/AppButton-D3qMd0Dr.js../public/vue-assets/assets/sentry-c0Rhilsu.js:./public/vue-assets/assets/OrgSettingsLayout-ByQjX4wG.js./public/vue-assets/assets/vuex.esm-bundler-DqfufJ2-.js./public/vue-assets/assets/playback-BH9MB7YC.js./public/vue-assets/assets/index.module-Bjlhgfdl.js./public/vue-assets/assets/intl-tel-input-BW4mv40Q.js./public/vue-assets/assets/team-insights-BqCZQtVc.js../public/vue-assets/assets/popper-CQwVcrX4.js../public/vue-assets/assets/PhoneField-CwCIoAYm.js./public/vue-assets/assets/live-XL9ZmWsU.js./public/vue-assets/assets/video-js-skin.less_vue_type_style_index_0_src_true_lang-BN0485xV.js../public/vue-assets/assets/index-CPycMtZF.js../public/vue-assets/assets/logged-in-layout-B_JDhk5y.js• ₴526.88kB27.91kB30.75kB34.39kB39.50kB39.69kB41.91kB43.22kB47.84kB48.28kB55.13kB61.28kB62.98kB63.26kB64.66kB79.60kB82.59kB94.84kB115.71kB117.59 kВ120.67kB128.71 kB129.28 kB133.44 kB164.28kB176.33kB180.40kB198.79kB218.14kB264.94 kВ298.57kB307.13kB343.99kB367.43 kB689.63kB825.23 kB1,402.70 kB• built in 28.48s[plugin builtin:vite-reporter](!) Some chunksare larger than 500 kB after minification. Consider:- Using dynamic import() to code-split the application- Use build.rolldown0ptions.output.codeSplitting to improve chunking: [URL_WITH_CREDENTIALS] 88APP...
|
NULL
|
|
47110
|
NULL
|
0
|
2026-04-17T11:08:43.572469+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776424123572_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorToo PhpStormFileEditViewNavigateCodeLaravelRefactorToolsWindowHelpFV faVsco.s vT° JY-20692-fix-integration-app-[API_KEY].php-cs-fixer.dist.phpphp.phpstorm.meta.php=.phpunit.result.cacheE.prettierignoreE windsurfrulespip _lde_nelper.pnppnpce neloermoces.onepnp aruisani composer.isonO composer.lockdependency-checker.jsonO dev.jsonE ids.txt=infection.json.distM-INSALL.mdM+ INTERNAL WEBHOOK SETUPjiminny_storageM+ licenses.mdM MakefileOpackage-lock.json= phpstan.neon.dist= phpstan-baseline.neon< phpunit.xmlTa raw_sqLquery.sqlM+ README.mdsonar-project.properties= test.py<> Untitled Diagram.xmlI5 vetur.config.jsM-+ WEbnOOK HILIEKING_IMPLE› Iib External Librariesv E® Scratches and Consolesv D Database Consoles101819281030cachedStagesCcW165trait OpportunitySyncTraitA33 X2 X19 A Y 166private function resolveForecastCategory(?string $forecastCategory): stringf...1672 usages=169private function importExternalFieldData(array $properties, int $opportunityIa, 170171$crmFields = $this->getOpportunitySyncableFieldsO;=172$this->import0pportunityCrmFieldData(Sproperties, $crmFields, Sopportunityi17310331034103510481049105010511052=175176Lusaeesprivate function importOpportunityContacts(Opportunity Sopportunity, array $as:177=179/*** Sync opportunity contacts using differential approach180* This compares current vs new associations and only makes necessary changes1811821 usageprivate function syncOpportunityContactsDifferential(Opportunity $opportunity,10701 usageprivate function getcurrentContactCrmIds(Opportunity $opportunity): array{..., 187A console [EU]4 DEAL RISKS [EU]A DI [EU]dEuleUv& jiminny@localhost& console [jiminny@localr4 DI liiminnv@localhosti4 HS_local [jiminny@localA SF [jiminny@localhost]A zoho_dev [jiminny@loceY A PROD© AutomatedReportsService.phpC ReportController.php© SendReportJob.phplokenbullaer.onoc leamsetuocontroller.onp• Filesystem.php© Team.php© RequestGenerateReportJob.phpT InteractsWithPivotTable.phg© CreateHeldActivityEvent.phpOpportunitySyncTrait.phpC OpportunityUpdated.php© SendReportMailJob.phpphp api.php© TrackProviderlnstalledEvent.phpC Opportunity.phpC OpportunityStageUpdated.phpc) FventService?rovider.onm© RunOpportunityAiAnalysis.php( ImportBatchJobTrait.php© OpportunityPendingAiAnalysisAfterStageChanged.php© ProcessAiAutomationAnalysisResults.php(C) Service.php© ImportOpportunityBatch.phpД0/1107710781079108010811 usageprivate function logContactAssociationChanges(Opportunity Sopportunity,array $currentContactCrmIds,array $contactAssociations,array $contactsToAdd,array +concacustokemove): void {...}-189190191192services+,o₫|v D DatabasevdtuA console 1 s 59 msuni crm conticurations "s 391 msdjiminny@localhost4SF4 HS_localV A PROD4 console 1 s 806 msV A STAGINGA consoleOutputf # 16952 rows y+=S9TTy: Autoy1y3VLf6XLVLQRQd0jUSrKV7cXnoDDB6zgwKg0ozEBvu2HnCmKsNV…2 CIsImtpZCI6IjVmNWFhZGFkLTQwZDktND1kNy04ZWI2LTAzNmN1M...QM0AФW expires Y177642648417296136151776509176refresh_token_expires Y! provider Yu coodle<null> zoom-phone<null> integration-appShortcuts conflicts: Clone Caret Above and 1 more shortottings. // Modify Shortcuts // Don't Show Again (today 12:22)= custom.log< Hs local liminnyalocalnostA console [PROD]E laravel.logA SF [jiminny@localhost] XC scratch_1jsonA console (EU]© CrmEntityRepository.phpA console [(STAGING]V connect.vuefi crm_configurations (EU]V Onboard.vueXAutovPlaygroundSELECT * FROM automated_reports order by za desc;SELECT * FROMautomated_report_results order by id desc;select * from activity_searches where user_id = 143;select * from ask_anything_prompts;ma liminnvv09 A12 V2 V4 л VSELECT * FROM groups WHERE id = 1439;SELECT * FROM users WHERE group_id = 1439;select * from permissions; # 158select * from roles;select * from permission_role,select * from teams where id = 1;select * from groups g JOIN playbooks p 1.n<->1: on g.playbook_id = p.id where g.team_id = 1;select * from groups where id = 28;select * from playbooks where team_id = 1;select * from playbooks where id = 179;select * from playbook_categories where id = 1391;select * from users where id = 143;select * from crm_profiles where user_id = 143;select * from activities where crm_configuration_id = 39 and type = 'conference'and crm_provider_id IS NOT NULL ORDER by id desc;select * from activities where id = 422003; # 00U0400000pB6FpMACSELECT ar.ig, ar.wuig, ar.mediantxpe, ar.status, a.txReFROM automated_report_results arJOIN automated_reports a ON a.id = ar.repontaidWHERE a.txpe = 'ask_jiminnx"LIMIT 10;select * from teams where id = 3143;select * from crm_configurations where id = 500;select * from users where name = 'Integration Account'; # 1695SELECT * FROM social_accounts WHERE sociable_id = 1695;f Support Daily • in 52 mAL AutomatedReportsCommandTestv100% 145Fri 17 Apr 14:08:43CascadeAutomated Report RetrDebugging OpportunitUpdate Connection Loconnect.vue tront-end/src/components/connecnothing is there"cant see log'sThe console is full of CSP warnings and errors but none of your [IntegrationApp] logs. This means intedrat1onApponcL1ck Is never belno called at all — the burton click isn't reachine that tunction.Check:1. Is the button actually calling integrationApp0nClick? - the click handler might be bound to adifferent method or the component re-rendered2. Is the frontend bundle rebuilt? — you may be running a stale build that doesn't include your changesSearched.intecrationAoo@nClickintront-enc/src/coents/connect/connect.vue (2)The binding looks correct. The most likely issue is a stale build - your changes aren't compiled yet. Add aquick test: put a console.log before the IntegrationAppClient instantiation that's impossible to miss:Reao connectvue :158-|44• connect.vueNow after saving, hard refresh the browser ( Cmd+Shift+R ) to bust the cache. If you still don't see [IntegraLlonAdo intecracionAppuncllck called arter cllckine tne ourion, tne dev server nash t olckea ue tnechanges or the click event isn't reaching this component (e.g. another element is intercepting it).O ll .wAsk anything (284L)+ <> Code Claude Sonnet 4.6I state Yconnectedfull-refreshfull-refresheI auth_scope Ynttps://www.googleap1s.com/auch/user1nro.ema1l open1d nuups://www.googleap1s.com/auch/calendar nuups..phone:read:admin user:read:adminSHULLeCSV vДOB,Wretry_after YID creaShULD2026-04<nul><null>2024-12026-04W Windsurf Teams 192:20 0 UTF-84 spaces...
|
NULL
|
-7584938818329207593
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorToo PhpStormFileEditViewNavigateCodeLaravelRefactorToolsWindowHelpFV faVsco.s vT° JY-20692-fix-integration-app-[API_KEY].php-cs-fixer.dist.phpphp.phpstorm.meta.php=.phpunit.result.cacheE.prettierignoreE windsurfrulespip _lde_nelper.pnppnpce neloermoces.onepnp aruisani composer.isonO composer.lockdependency-checker.jsonO dev.jsonE ids.txt=infection.json.distM-INSALL.mdM+ INTERNAL WEBHOOK SETUPjiminny_storageM+ licenses.mdM MakefileOpackage-lock.json= phpstan.neon.dist= phpstan-baseline.neon< phpunit.xmlTa raw_sqLquery.sqlM+ README.mdsonar-project.properties= test.py<> Untitled Diagram.xmlI5 vetur.config.jsM-+ WEbnOOK HILIEKING_IMPLE› Iib External Librariesv E® Scratches and Consolesv D Database Consoles101819281030cachedStagesCcW165trait OpportunitySyncTraitA33 X2 X19 A Y 166private function resolveForecastCategory(?string $forecastCategory): stringf...1672 usages=169private function importExternalFieldData(array $properties, int $opportunityIa, 170171$crmFields = $this->getOpportunitySyncableFieldsO;=172$this->import0pportunityCrmFieldData(Sproperties, $crmFields, Sopportunityi17310331034103510481049105010511052=175176Lusaeesprivate function importOpportunityContacts(Opportunity Sopportunity, array $as:177=179/*** Sync opportunity contacts using differential approach180* This compares current vs new associations and only makes necessary changes1811821 usageprivate function syncOpportunityContactsDifferential(Opportunity $opportunity,10701 usageprivate function getcurrentContactCrmIds(Opportunity $opportunity): array{..., 187A console [EU]4 DEAL RISKS [EU]A DI [EU]dEuleUv& jiminny@localhost& console [jiminny@localr4 DI liiminnv@localhosti4 HS_local [jiminny@localA SF [jiminny@localhost]A zoho_dev [jiminny@loceY A PROD© AutomatedReportsService.phpC ReportController.php© SendReportJob.phplokenbullaer.onoc leamsetuocontroller.onp• Filesystem.php© Team.php© RequestGenerateReportJob.phpT InteractsWithPivotTable.phg© CreateHeldActivityEvent.phpOpportunitySyncTrait.phpC OpportunityUpdated.php© SendReportMailJob.phpphp api.php© TrackProviderlnstalledEvent.phpC Opportunity.phpC OpportunityStageUpdated.phpc) FventService?rovider.onm© RunOpportunityAiAnalysis.php( ImportBatchJobTrait.php© OpportunityPendingAiAnalysisAfterStageChanged.php© ProcessAiAutomationAnalysisResults.php(C) Service.php© ImportOpportunityBatch.phpД0/1107710781079108010811 usageprivate function logContactAssociationChanges(Opportunity Sopportunity,array $currentContactCrmIds,array $contactAssociations,array $contactsToAdd,array +concacustokemove): void {...}-189190191192services+,o₫|v D DatabasevdtuA console 1 s 59 msuni crm conticurations "s 391 msdjiminny@localhost4SF4 HS_localV A PROD4 console 1 s 806 msV A STAGINGA consoleOutputf # 16952 rows y+=S9TTy: Autoy1y3VLf6XLVLQRQd0jUSrKV7cXnoDDB6zgwKg0ozEBvu2HnCmKsNV…2 CIsImtpZCI6IjVmNWFhZGFkLTQwZDktND1kNy04ZWI2LTAzNmN1M...QM0AФW expires Y177642648417296136151776509176refresh_token_expires Y! provider Yu coodle<null> zoom-phone<null> integration-appShortcuts conflicts: Clone Caret Above and 1 more shortottings. // Modify Shortcuts // Don't Show Again (today 12:22)= custom.log< Hs local liminnyalocalnostA console [PROD]E laravel.logA SF [jiminny@localhost] XC scratch_1jsonA console (EU]© CrmEntityRepository.phpA console [(STAGING]V connect.vuefi crm_configurations (EU]V Onboard.vueXAutovPlaygroundSELECT * FROM automated_reports order by za desc;SELECT * FROMautomated_report_results order by id desc;select * from activity_searches where user_id = 143;select * from ask_anything_prompts;ma liminnvv09 A12 V2 V4 л VSELECT * FROM groups WHERE id = 1439;SELECT * FROM users WHERE group_id = 1439;select * from permissions; # 158select * from roles;select * from permission_role,select * from teams where id = 1;select * from groups g JOIN playbooks p 1.n<->1: on g.playbook_id = p.id where g.team_id = 1;select * from groups where id = 28;select * from playbooks where team_id = 1;select * from playbooks where id = 179;select * from playbook_categories where id = 1391;select * from users where id = 143;select * from crm_profiles where user_id = 143;select * from activities where crm_configuration_id = 39 and type = 'conference'and crm_provider_id IS NOT NULL ORDER by id desc;select * from activities where id = 422003; # 00U0400000pB6FpMACSELECT ar.ig, ar.wuig, ar.mediantxpe, ar.status, a.txReFROM automated_report_results arJOIN automated_reports a ON a.id = ar.repontaidWHERE a.txpe = 'ask_jiminnx"LIMIT 10;select * from teams where id = 3143;select * from crm_configurations where id = 500;select * from users where name = 'Integration Account'; # 1695SELECT * FROM social_accounts WHERE sociable_id = 1695;f Support Daily • in 52 mAL AutomatedReportsCommandTestv100% 145Fri 17 Apr 14:08:43CascadeAutomated Report RetrDebugging OpportunitUpdate Connection Loconnect.vue tront-end/src/components/connecnothing is there"cant see log'sThe console is full of CSP warnings and errors but none of your [IntegrationApp] logs. This means intedrat1onApponcL1ck Is never belno called at all — the burton click isn't reachine that tunction.Check:1. Is the button actually calling integrationApp0nClick? - the click handler might be bound to adifferent method or the component re-rendered2. Is the frontend bundle rebuilt? — you may be running a stale build that doesn't include your changesSearched.intecrationAoo@nClickintront-enc/src/coents/connect/connect.vue (2)The binding looks correct. The most likely issue is a stale build - your changes aren't compiled yet. Add aquick test: put a console.log before the IntegrationAppClient instantiation that's impossible to miss:Reao connectvue :158-|44• connect.vueNow after saving, hard refresh the browser ( Cmd+Shift+R ) to bust the cache. If you still don't see [IntegraLlonAdo intecracionAppuncllck called arter cllckine tne ourion, tne dev server nash t olckea ue tnechanges or the click event isn't reaching this component (e.g. another element is intercepting it).O ll .wAsk anything (284L)+ <> Code Claude Sonnet 4.6I state Yconnectedfull-refreshfull-refresheI auth_scope Ynttps://www.googleap1s.com/auch/user1nro.ema1l open1d nuups://www.googleap1s.com/auch/calendar nuups..phone:read:admin user:read:adminSHULLeCSV vДOB,Wretry_after YID creaShULD2026-04<nul><null>2024-12026-04W Windsurf Teams 192:20 0 UTF-84 spaces...
|
47108
|
|
47200
|
NULL
|
0
|
2026-04-17T11:13:58.138724+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776424438138_m1.jpg...
|
Firefox
|
Jiminny — Work
|
True
|
app.dev.jiminny.com/onboard
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Update your information
Update your information
GENERAL
TIMEZONE
Select option Europe/Sofia (UTC +03:00)
Select option
Europe/Sofia (UTC +03:00)
*
LANGUAGES SPOKEN DURING CALLS
DEFAULT SPOKEN LANGUAGE
Select option English (United Kingdom)
Select option
English (United Kingdom)
*
If the language isn't detected we'll default to this one
Add language
CONNECT/SYNC SETTINGS
Connect Zoho CRM
zohocrm Sign in with Zoho CRM
Sign in with Zoho CRM
Import Calendar Meetings
*
google Sign in with Google
Sign in with Google
Let's Get Started!
Clear the Web Console output (⌘K, Ctrl+L)
Integr
Integr
214 hidden
Clear filter input
Errors
Warnings
Info
Logs
Debug
CSS
XHR
Requests
Console Settings
Navigated to https://app.dev.jiminny.com/dashboard
Show/hide message details.
XHR
GET
https://app.dev.jiminny.com/api/v1/integration-app-token
https://app.dev.jiminny.com/api/v1/integration-app-token
[HTTP/2
200
1450ms]
Top
Switch to multi-line editor mode (Cmd + B)
...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"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":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Jiminny","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":"Open bookmarks (⌘B)","depth":6,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Update your information","depth":9,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Update your information","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GENERAL","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TIMEZONE","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXComboBox","text":"Select option Europe/Sofia (UTC +03:00)","depth":10,"value":"Select option Europe/Sofia (UTC +03:00)","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Select option","depth":11,"help_text":"","role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Europe/Sofia (UTC +03:00)","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"*","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"LANGUAGES SPOKEN DURING CALLS","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DEFAULT SPOKEN LANGUAGE","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXComboBox","text":"Select option English (United Kingdom)","depth":10,"value":"Select option English (United Kingdom)","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Select option","depth":11,"help_text":"","role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"English (United Kingdom)","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"*","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"If the language isn't detected we'll default to this one","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add language","depth":11,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CONNECT/SYNC SETTINGS","depth":10,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Connect Zoho CRM","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"zohocrm Sign in with Zoho CRM","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXStaticText","text":"Sign in with Zoho CRM","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Import Calendar Meetings","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"*","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"google Sign in with Google","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sign in with Google","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Let's Get Started!","depth":9,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Clear the Web Console output (⌘K, Ctrl+L)","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXTextField","text":"Integr","depth":15,"value":"Integr","role_description":"search text field","subrole":"AXSearchField","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Integr","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"214 hidden","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Clear filter input","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Errors","depth":15,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Warnings","depth":15,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Info","depth":15,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Logs","depth":15,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Debug","depth":15,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"CSS","depth":15,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"XHR","depth":15,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Requests","depth":15,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Console Settings","depth":15,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Navigated to https://app.dev.jiminny.com/dashboard","depth":20,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"XHR","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com/api/v1/integration-app-token","depth":20,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com/api/v1/integration-app-token","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/2","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1450ms]","depth":21,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Top","depth":16,"help_text":"Select evaluation context","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Switch to multi-line editor mode (Cmd + B)","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":18,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-6936402857171103518
|
-7241824426406463412
|
click
|
accessibility
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Update your information
Update your information
GENERAL
TIMEZONE
Select option Europe/Sofia (UTC +03:00)
Select option
Europe/Sofia (UTC +03:00)
*
LANGUAGES SPOKEN DURING CALLS
DEFAULT SPOKEN LANGUAGE
Select option English (United Kingdom)
Select option
English (United Kingdom)
*
If the language isn't detected we'll default to this one
Add language
CONNECT/SYNC SETTINGS
Connect Zoho CRM
zohocrm Sign in with Zoho CRM
Sign in with Zoho CRM
Import Calendar Meetings
*
google Sign in with Google
Sign in with Google
Let's Get Started!
Clear the Web Console output (⌘K, Ctrl+L)
Integr
Integr
214 hidden
Clear filter input
Errors
Warnings
Info
Logs
Debug
CSS
XHR
Requests
Console Settings
Navigated to https://app.dev.jiminny.com/dashboard
Show/hide message details.
XHR
GET
https://app.dev.jiminny.com/api/v1/integration-app-token
https://app.dev.jiminny.com/api/v1/integration-app-token
[HTTP/2
200
1450ms]
Top
Switch to multi-line editor mode (Cmd + B)
...
|
47196
|
|
47201
|
NULL
|
0
|
2026-04-17T11:14:00.766529+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776424440766_m2.jpg...
|
Firefox
|
Jiminny — Work
|
True
|
app.dev.jiminny.com/onboard
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Update your information
Update your information
GENERAL
TIMEZONE
Select option Europe/Sofia (UTC +03:00)
Select option
Europe/Sofia (UTC +03:00)
*
LANGUAGES SPOKEN DURING CALLS
DEFAULT SPOKEN LANGUAGE
Select option English (United Kingdom)
Select option
English (United Kingdom)
*
If the language isn't detected we'll default to this one
Add language
CONNECT/SYNC SETTINGS
Connect Zoho CRM
zohocrm Sign in with Zoho CRM
Sign in with Zoho CRM
Import Calendar Meetings
*
google Sign in with Google
Sign in with Google
Let's Get Started!
Zoho CRM Zoho CRM
Zoho CRM
Linking your Zoho CRM account
Linking your Zoho CRM account
Please select one of authentication options:
Connect via Membrane
Connect via Membrane
Connect via Membrane
OAuth 2.0
OAuth 2.0
OAuth 2.0
Close Dialog
Clear the Web Console output (⌘K, Ctrl+L)
Integr
Integr
230 hidden
Clear filter input
Errors
Warnings
Info
Logs
Debug
CSS
XHR
Requests
Console Settings
Navigated to https://app.dev.jiminny.com/dashboard
Show/hide message details.
XHR
GET
https://app.dev.jiminny.com/api/v1/integration-app-token
https://app.dev.jiminny.com/api/v1/integration-app-token
[HTTP/2
200
1450ms]
Show/hide message details.
GET
https://ui.integration.app/embed/integrations/zohocrm/connect?token=[JWT_TOKEN]&showPoweredBy=false&allowMultipleConnections=
https://ui.integration.app/embed/integrations/zohocrm/connect?token=[JWT_TOKEN]&showPoweredBy=false&allowMultipleConnections=
[HTTP/2
200
226ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-DQ2tm1dS.css
https://ui.integration.app/assets/index-DQ2tm1dS.css
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-DCou__JW.js
https://ui.integration.app/assets/index-DCou__JW.js
[HTTP/3
200
0ms]
None of the “sha384” hashes in the integrity attribute match the content of the subresource at “
https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&display=swap
https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&display=swap
”. The computed hash is “oxpr/SPifeVqNx5O/1ow9nS0QIt60XJIKkUcSrPclwH/ruMEWK7C1JNqlqUMCV5N”.
connect
connect
Show/hide message details.
GET
https://ui.integration.app/assets/RefreshConnectionPopup-P6RvdGCd.js
https://ui.integration.app/assets/RefreshConnectionPopup-P6RvdGCd.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-DRKPh6L6.js
https://ui.integration.app/assets/index-DRKPh6L6.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/RefreshConnectionPopup-_TEQhDXU.css
https://ui.integration.app/assets/RefreshConnectionPopup-_TEQhDXU.css
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/IntegrationConnectionGuard-CgFvoH9G.js
https://ui.integration.app/assets/IntegrationConnectionGuard-CgFvoH9G.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-7Ka1N6pQ.js
https://ui.integration.app/assets/index-7Ka1N6pQ.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/Routes-CDWw4D3f.js
https://ui.integration.app/assets/Routes-CDWw4D3f.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-Dg8yQbmF.js
https://ui.integration.app/assets/index-Dg8yQbmF.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/ResetButton-B5vGAh3h.js
https://ui.integration.app/assets/ResetButton-B5vGAh3h.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/flow-instance-context-CJr_s6BC.js
https://ui.integration.app/assets/flow-instance-context-CJr_s6BC.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-BhMG5VAy.css
https://ui.integration.app/assets/index-BhMG5VAy.css
[HTTP/3
200
0ms]
Show/hide message details....
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"bounds":{"left":0.00234375,"top":0.045138888,"width":0.0890625,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"bounds":{"left":0.0,"top":0.08263889,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"bounds":{"left":0.015625,"top":0.09236111,"width":0.04453125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.0,"top":0.11111111,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.015625,"top":0.12083333,"width":0.11445312,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.0,"top":0.13958333,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.015625,"top":0.14930555,"width":0.17734376,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"bounds":{"left":0.0,"top":0.16805555,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"bounds":{"left":0.015625,"top":0.17777778,"width":0.048828125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.19652778,"width":0.09375,"height":0.028472222},"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.015625,"top":0.20625,"width":0.017578125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"bounds":{"left":0.0,"top":0.225,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"bounds":{"left":0.015625,"top":0.23472223,"width":0.1515625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2534722,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"bounds":{"left":0.015625,"top":0.26319444,"width":0.17421874,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"bounds":{"left":0.0,"top":0.28194445,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"bounds":{"left":0.015625,"top":0.29166666,"width":0.09726562,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.0,"top":0.31041667,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Jiminny","depth":5,"bounds":{"left":0.015625,"top":0.3201389,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.07890625,"top":0.31666666,"width":0.009375,"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.003125,"top":0.3402778,"width":0.08710937,"height":0.022222223},"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.003125,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.01640625,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.029296875,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.0421875,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.05546875,"top":0.97430557,"width":0.0125,"height":0.022222223},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Update your information","depth":9,"bounds":{"left":0.29335937,"top":0.3375,"width":0.15273437,"height":0.016666668},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Update your information","depth":10,"bounds":{"left":0.31796876,"top":0.33541667,"width":0.103515625,"height":0.020833334},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GENERAL","depth":10,"bounds":{"left":0.29335937,"top":0.36944443,"width":0.025,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"TIMEZONE","depth":12,"bounds":{"left":0.2984375,"top":0.39722222,"width":0.0203125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXComboBox","text":"Select option Europe/Sofia (UTC +03:00)","depth":10,"bounds":{"left":0.2984375,"top":0.40625,"width":0.14726563,"height":0.017361112},"value":"Select option Europe/Sofia (UTC +03:00)","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Select option","depth":11,"help_text":"","role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Europe/Sofia (UTC +03:00)","depth":12,"bounds":{"left":0.2984375,"top":0.40902779,"width":0.061328124,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"*","depth":11,"bounds":{"left":0.44023436,"top":0.3923611,"width":0.003125,"height":0.017361112},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"LANGUAGES SPOKEN DURING CALLS","depth":10,"bounds":{"left":0.29335937,"top":0.44791666,"width":0.09609375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DEFAULT SPOKEN LANGUAGE","depth":12,"bounds":{"left":0.2984375,"top":0.47569445,"width":0.05546875,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXComboBox","text":"Select option English (United Kingdom)","depth":10,"bounds":{"left":0.2984375,"top":0.48472223,"width":0.14726563,"height":0.017361112},"value":"Select option English (United Kingdom)","help_text":"","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Select option","depth":11,"help_text":"","role_description":"text field","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"English (United Kingdom)","depth":12,"bounds":{"left":0.2984375,"top":0.4875,"width":0.057421874,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"*","depth":11,"bounds":{"left":0.44023436,"top":0.47083333,"width":0.003125,"height":0.017361112},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"If the language isn't detected we'll default to this one","depth":11,"bounds":{"left":0.29335937,"top":0.5076389,"width":0.109375,"height":0.010416667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add language","depth":11,"bounds":{"left":0.29335937,"top":0.52847224,"width":0.037890624,"height":0.013888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CONNECT/SYNC SETTINGS","depth":10,"bounds":{"left":0.29335937,"top":0.56458336,"width":0.06992187,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Connect Zoho CRM","depth":11,"bounds":{"left":0.29335937,"top":0.5902778,"width":0.0546875,"height":0.013888889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"zohocrm Sign in with Zoho CRM","depth":10,"bounds":{"left":0.36875,"top":0.5847222,"width":0.07734375,"height":0.025},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sign in with Zoho CRM","depth":11,"bounds":{"left":0.38632813,"top":0.59097224,"width":0.054296874,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Import Calendar Meetings","depth":11,"bounds":{"left":0.29335937,"top":0.62083334,"width":0.045703124,"height":0.027777778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"*","depth":12,"bounds":{"left":0.3371094,"top":0.6173611,"width":0.003125,"height":0.017361112},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"google Sign in with Google","depth":10,"bounds":{"left":0.36875,"top":0.62222224,"width":0.07734375,"height":0.025},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sign in with Google","depth":11,"bounds":{"left":0.39023438,"top":0.6284722,"width":0.046484374,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Let's Get Started!","depth":9,"bounds":{"left":0.3347656,"top":0.68333334,"width":0.06992187,"height":0.025},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":false,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Zoho CRM Zoho CRM","depth":12,"bounds":{"left":0.16835937,"top":0.17777778,"width":0.20625,"height":0.016666668},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Zoho CRM","depth":13,"bounds":{"left":0.18085937,"top":0.17986111,"width":0.02578125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Linking your Zoho CRM account","depth":14,"bounds":{"left":0.18046875,"top":0.30069444,"width":0.19453125,"height":0.04236111},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Linking your Zoho CRM account","depth":15,"bounds":{"left":0.18046875,"top":0.31180555,"width":0.134375,"height":0.02013889},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Please select one of authentication options:","depth":15,"bounds":{"left":0.18046875,"top":0.33541667,"width":0.10625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Connect via Membrane","depth":13,"bounds":{"left":0.16679688,"top":0.35694444,"width":0.221875,"height":0.033333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Connect via Membrane","depth":15,"bounds":{"left":0.19453125,"top":0.36805555,"width":0.055859376,"height":0.009722223},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Connect via Membrane","depth":16,"bounds":{"left":0.19453125,"top":0.3673611,"width":0.055859376,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"OAuth 2.0","depth":13,"bounds":{"left":0.16679688,"top":0.39097223,"width":0.221875,"height":0.033333335},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"OAuth 2.0","depth":15,"bounds":{"left":0.19453125,"top":0.40208334,"width":0.023828125,"height":0.009722223},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"OAuth 2.0","depth":16,"bounds":{"left":0.19453125,"top":0.40138888,"width":0.023828125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close Dialog","depth":12,"bounds":{"left":0.3816406,"top":0.17638889,"width":0.0109375,"height":0.019444445},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXButton","text":"Clear the Web Console output (⌘K, Ctrl+L)","depth":15,"bounds":{"left":0.46328124,"top":0.068055555,"width":0.01015625,"height":0.013888889},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXTextField","text":"Integr","depth":15,"bounds":{"left":0.47578126,"top":0.065972224,"width":0.32851562,"height":0.018055556},"value":"Integr","role_description":"search text field","subrole":"AXSearchField","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Integr","depth":16,"bounds":{"left":0.484375,"top":0.07013889,"width":0.012109375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"230 hidden","depth":16,"bounds":{"left":0.8058594,"top":0.07013889,"width":0.0234375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Clear filter input","depth":15,"bounds":{"left":0.83085936,"top":0.06944445,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Errors","depth":15,"bounds":{"left":0.8417969,"top":0.068055555,"width":0.0171875,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Warnings","depth":15,"bounds":{"left":0.85976565,"top":0.068055555,"width":0.023828125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Info","depth":15,"bounds":{"left":0.884375,"top":0.068055555,"width":0.0125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Logs","depth":15,"bounds":{"left":0.89765626,"top":0.068055555,"width":0.014453125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Debug","depth":15,"bounds":{"left":0.9128906,"top":0.068055555,"width":0.01796875,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"CSS","depth":15,"bounds":{"left":0.9359375,"top":0.068055555,"width":0.01328125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"XHR","depth":15,"bounds":{"left":0.95,"top":0.068055555,"width":0.013671875,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Requests","depth":15,"bounds":{"left":0.9644531,"top":0.068055555,"width":0.023828125,"height":0.013888889},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Console Settings","depth":15,"bounds":{"left":0.9898437,"top":0.06527778,"width":0.01015625,"height":0.019444445},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Navigated to https://app.dev.jiminny.com/dashboard","depth":20,"bounds":{"left":0.471875,"top":0.0875,"width":0.12929687,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.10138889,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"XHR","depth":21,"bounds":{"left":0.475,"top":0.10277778,"width":0.00703125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.48476562,"top":0.10208333,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://app.dev.jiminny.com/api/v1/integration-app-token","depth":20,"bounds":{"left":0.4953125,"top":0.10208333,"width":0.4441406,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://app.dev.jiminny.com/api/v1/integration-app-token","depth":21,"bounds":{"left":0.4953125,"top":0.10208333,"width":0.14453125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/2","depth":21,"bounds":{"left":0.9421875,"top":0.10208333,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.96367186,"top":0.10208333,"width":0.007421875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"1450ms]","depth":21,"bounds":{"left":0.971875,"top":0.10208333,"width":0.0234375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.11666667,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.11736111,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/embed/integrations/zohocrm/connect?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjFlY2U2NmM4LWZlYjEtNGRmMS1iMzIxLTIxNjA3ZGFmNDYyMyIsIm5hbWUiOiJEZXYgWm9obyBDUk0gY2xpZW50IiwiaXNzIjoiNjg3YTU5ZjctMjI3Ni00ODZjLTgzMDYtMTQ1MDdmZDc5N2FlIiwiZXhwIjoxNzc2NTEwODMyLCJmaWVsZHMiOnsiZW52IjoibG9jYWwiLCJ3ZWJob29rVXJsIjoiaHR0cHM6Ly9xYXRlc3Q6UWFZZU14MS02NDJuYkBsdWthc2submdyb2suaW8iLCJldmVudEFjY291bnREZWxldGVkIjoiL3dlYmhvb2svaW50YXBwL2FjYy1kZWwiLCJldmVudENvbnRhY3REZWxldGVkIjoiL3dlYmhvb2svaW50YXBwL2NudC1kZWwiLCJldmVudFByb2ZpbGVEZWxldGVkIjoiL3dlYmhvb2svaW50YXBwL3Vzci1kZWwiLCJldmVudExlYWREZWxldGVkIjoiL3dlYmhvb2svaW50YXBwL2xkLWRlbCIsImV2ZW50RGVhbERlbGV0ZWQiOiIvd2ViaG9vay9pbnRhcHAvb3BwLWRlbCIsImV2ZW50QWNjb3VudENyZWF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvYWNjLWFkZCIsImV2ZW50QWNjb3VudFVwZGF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvYWNjLXVwZCIsImV2ZW50Q29udGFjdENyZWF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvY250LWFkZCIsImV2ZW50Q29udGFjdFVwZGF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvY250LXVwZCIsImV2ZW50UHJvZmlsZUNyZWF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvdXNyLWFkZCIsImV2ZW50UHJvZmlsZVVwZGF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvdXNyLXVwZCIsImV2ZW50TGVhZENyZWF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvbGQtYWRkIiwiZXZlbnRMZWFkVXBkYXRlZCI6Ii93ZWJob29rL2ludGFwcC9sZC11cGQiLCJldmVudERlYWxDcmVhdGVkIjoiL3dlYmhvb2svaW50YXBwL29wcC1hZGQiLCJldmVudERlYWxVcGRhdGVkIjoiL3dlYmhvb2svaW50YXBwL29wcC11cGQifX0.cPowBZuDxoTPGjFBae4ToiaiHGn8n7ip72tabpSeGsg&showPoweredBy=false&allowMultipleConnections=","depth":20,"bounds":{"left":0.484375,"top":0.11736111,"width":0.4578125,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/embed/integrations/zohocrm/connect?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjFlY2U2NmM4LWZlYjEtNGRmMS1iMzIxLTIxNjA3ZGFmNDYyMyIsIm5hbWUiOiJEZXYgWm9obyBDUk0gY2xpZW50IiwiaXNzIjoiNjg3YTU5ZjctMjI3Ni00ODZjLTgzMDYtMTQ1MDdmZDc5N2FlIiwiZXhwIjoxNzc2NTEwODMyLCJmaWVsZHMiOnsiZW52IjoibG9jYWwiLCJ3ZWJob29rVXJsIjoiaHR0cHM6Ly9xYXRlc3Q6UWFZZU14MS02NDJuYkBsdWthc2submdyb2suaW8iLCJldmVudEFjY291bnREZWxldGVkIjoiL3dlYmhvb2svaW50YXBwL2FjYy1kZWwiLCJldmVudENvbnRhY3REZWxldGVkIjoiL3dlYmhvb2svaW50YXBwL2NudC1kZWwiLCJldmVudFByb2ZpbGVEZWxldGVkIjoiL3dlYmhvb2svaW50YXBwL3Vzci1kZWwiLCJldmVudExlYWREZWxldGVkIjoiL3dlYmhvb2svaW50YXBwL2xkLWRlbCIsImV2ZW50RGVhbERlbGV0ZWQiOiIvd2ViaG9vay9pbnRhcHAvb3BwLWRlbCIsImV2ZW50QWNjb3VudENyZWF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvYWNjLWFkZCIsImV2ZW50QWNjb3VudFVwZGF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvYWNjLXVwZCIsImV2ZW50Q29udGFjdENyZWF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvY250LWFkZCIsImV2ZW50Q29udGFjdFVwZGF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvY250LXVwZCIsImV2ZW50UHJvZmlsZUNyZWF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvdXNyLWFkZCIsImV2ZW50UHJvZmlsZVVwZGF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvdXNyLXVwZCIsImV2ZW50TGVhZENyZWF0ZWQiOiIvd2ViaG9vay9pbnRhcHAvbGQtYWRkIiwiZXZlbnRMZWFkVXBkYXRlZCI6Ii93ZWJob29rL2ludGFwcC9sZC11cGQiLCJldmVudERlYWxDcmVhdGVkIjoiL3dlYmhvb2svaW50YXBwL29wcC1hZGQiLCJldmVudERlYWxVcGRhdGVkIjoiL3dlYmhvb2svaW50YXBwL29wcC11cGQifX0.cPowBZuDxoTPGjFBae4ToiaiHGn8n7ip72tabpSeGsg&showPoweredBy=false&allowMultipleConnections=","depth":21,"bounds":{"left":0.484375,"top":0.11736111,"width":0.515625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/2","depth":21,"bounds":{"left":0.94453126,"top":0.11736111,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.96601564,"top":0.11736111,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"226ms]","depth":21,"bounds":{"left":0.9746094,"top":0.11736111,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.13125,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.13194445,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/index-DQ2tm1dS.css","depth":20,"bounds":{"left":0.484375,"top":0.13194445,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/index-DQ2tm1dS.css","depth":21,"bounds":{"left":0.484375,"top":0.13194445,"width":0.134375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.13194445,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.13194445,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.13194445,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.14583333,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.14652778,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/index-DCou__JW.js","depth":20,"bounds":{"left":0.484375,"top":0.14652778,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/index-DCou__JW.js","depth":21,"bounds":{"left":0.484375,"top":0.14652778,"width":0.13203125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.14652778,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.14652778,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.14652778,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"None of the “sha384” hashes in the integrity attribute match the content of the subresource at “","depth":21,"bounds":{"left":0.471875,"top":0.16111112,"width":0.24804688,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&display=swap","depth":21,"bounds":{"left":0.7199219,"top":0.16111112,"width":0.17578125,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&display=swap","depth":22,"bounds":{"left":0.7199219,"top":0.16111112,"width":0.17578125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"”. The computed hash is “oxpr/SPifeVqNx5O/1ow9nS0QIt60XJIKkUcSrPclwH/ruMEWK7C1JNqlqUMCV5N”.","depth":21,"bounds":{"left":0.471875,"top":0.16111112,"width":0.5015625,"height":0.019444445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"connect","depth":20,"bounds":{"left":0.97734374,"top":0.16111112,"width":0.01796875,"height":0.009722223},"help_text":"View source in Debugger → https://ui.integration.app/embed/integrations/zohocrm/connect","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"connect","depth":22,"bounds":{"left":0.97734374,"top":0.16111112,"width":0.01796875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.18472221,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.18541667,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/RefreshConnectionPopup-P6RvdGCd.js","depth":20,"bounds":{"left":0.484375,"top":0.18541667,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/RefreshConnectionPopup-P6RvdGCd.js","depth":21,"bounds":{"left":0.484375,"top":0.18541667,"width":0.17578125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.18541667,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.18541667,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.18541667,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.19930555,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.2,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/index-DRKPh6L6.js","depth":20,"bounds":{"left":0.484375,"top":0.2,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/index-DRKPh6L6.js","depth":21,"bounds":{"left":0.484375,"top":0.2,"width":0.13203125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.2,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.2,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.2,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.21388888,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.21458334,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/RefreshConnectionPopup-_TEQhDXU.css","depth":20,"bounds":{"left":0.484375,"top":0.21458334,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/RefreshConnectionPopup-_TEQhDXU.css","depth":21,"bounds":{"left":0.484375,"top":0.21458334,"width":0.17851563,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.21458334,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.21458334,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.21458334,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.22847222,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.22916667,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/IntegrationConnectionGuard-CgFvoH9G.js","depth":20,"bounds":{"left":0.484375,"top":0.22916667,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/IntegrationConnectionGuard-CgFvoH9G.js","depth":21,"bounds":{"left":0.484375,"top":0.22916667,"width":0.18632813,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.22916667,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.22916667,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.22916667,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.24305555,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.24375,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/index-7Ka1N6pQ.js","depth":20,"bounds":{"left":0.484375,"top":0.24375,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/index-7Ka1N6pQ.js","depth":21,"bounds":{"left":0.484375,"top":0.24375,"width":0.13203125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.24375,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.24375,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.24375,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.2576389,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.25833333,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/Routes-CDWw4D3f.js","depth":20,"bounds":{"left":0.484375,"top":0.25833333,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/Routes-CDWw4D3f.js","depth":21,"bounds":{"left":0.484375,"top":0.25833333,"width":0.134375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.25833333,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.25833333,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.25833333,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.27222222,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.27291667,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/index-Dg8yQbmF.js","depth":20,"bounds":{"left":0.484375,"top":0.27291667,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/index-Dg8yQbmF.js","depth":21,"bounds":{"left":0.484375,"top":0.27291667,"width":0.13203125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.27291667,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.27291667,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.27291667,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.28680557,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.2875,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/ResetButton-B5vGAh3h.js","depth":20,"bounds":{"left":0.484375,"top":0.2875,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/ResetButton-B5vGAh3h.js","depth":21,"bounds":{"left":0.484375,"top":0.2875,"width":0.14765625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.2875,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.2875,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.2875,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.3013889,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.30208334,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/flow-instance-context-CJr_s6BC.js","depth":20,"bounds":{"left":0.484375,"top":0.30208334,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/flow-instance-context-CJr_s6BC.js","depth":21,"bounds":{"left":0.484375,"top":0.30208334,"width":0.1734375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.30208334,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.30208334,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.30208334,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.3159722,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"GET","depth":21,"bounds":{"left":0.47421876,"top":0.31666666,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"https://ui.integration.app/assets/index-BhMG5VAy.css","depth":20,"bounds":{"left":0.484375,"top":0.31666666,"width":0.46289062,"height":0.009722223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"https://ui.integration.app/assets/index-BhMG5VAy.css","depth":21,"bounds":{"left":0.484375,"top":0.31666666,"width":0.134375,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"[HTTP/3","depth":21,"bounds":{"left":0.9496094,"top":0.31666666,"width":0.020703126,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"200","depth":22,"bounds":{"left":0.9710938,"top":0.31666666,"width":0.0078125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"0ms]","depth":21,"bounds":{"left":0.9796875,"top":0.31666666,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show/hide message details.","depth":17,"bounds":{"left":0.46367186,"top":0.33055556,"width":0.009375,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
435485411657637066
|
-308816509618605563
|
visual_change
|
accessibility
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Update your information
Update your information
GENERAL
TIMEZONE
Select option Europe/Sofia (UTC +03:00)
Select option
Europe/Sofia (UTC +03:00)
*
LANGUAGES SPOKEN DURING CALLS
DEFAULT SPOKEN LANGUAGE
Select option English (United Kingdom)
Select option
English (United Kingdom)
*
If the language isn't detected we'll default to this one
Add language
CONNECT/SYNC SETTINGS
Connect Zoho CRM
zohocrm Sign in with Zoho CRM
Sign in with Zoho CRM
Import Calendar Meetings
*
google Sign in with Google
Sign in with Google
Let's Get Started!
Zoho CRM Zoho CRM
Zoho CRM
Linking your Zoho CRM account
Linking your Zoho CRM account
Please select one of authentication options:
Connect via Membrane
Connect via Membrane
Connect via Membrane
OAuth 2.0
OAuth 2.0
OAuth 2.0
Close Dialog
Clear the Web Console output (⌘K, Ctrl+L)
Integr
Integr
230 hidden
Clear filter input
Errors
Warnings
Info
Logs
Debug
CSS
XHR
Requests
Console Settings
Navigated to https://app.dev.jiminny.com/dashboard
Show/hide message details.
XHR
GET
https://app.dev.jiminny.com/api/v1/integration-app-token
https://app.dev.jiminny.com/api/v1/integration-app-token
[HTTP/2
200
1450ms]
Show/hide message details.
GET
https://ui.integration.app/embed/integrations/zohocrm/connect?token=[JWT_TOKEN]&showPoweredBy=false&allowMultipleConnections=
https://ui.integration.app/embed/integrations/zohocrm/connect?token=[JWT_TOKEN]&showPoweredBy=false&allowMultipleConnections=
[HTTP/2
200
226ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-DQ2tm1dS.css
https://ui.integration.app/assets/index-DQ2tm1dS.css
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-DCou__JW.js
https://ui.integration.app/assets/index-DCou__JW.js
[HTTP/3
200
0ms]
None of the “sha384” hashes in the integrity attribute match the content of the subresource at “
https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&display=swap
https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&display=swap
”. The computed hash is “oxpr/SPifeVqNx5O/1ow9nS0QIt60XJIKkUcSrPclwH/ruMEWK7C1JNqlqUMCV5N”.
connect
connect
Show/hide message details.
GET
https://ui.integration.app/assets/RefreshConnectionPopup-P6RvdGCd.js
https://ui.integration.app/assets/RefreshConnectionPopup-P6RvdGCd.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-DRKPh6L6.js
https://ui.integration.app/assets/index-DRKPh6L6.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/RefreshConnectionPopup-_TEQhDXU.css
https://ui.integration.app/assets/RefreshConnectionPopup-_TEQhDXU.css
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/IntegrationConnectionGuard-CgFvoH9G.js
https://ui.integration.app/assets/IntegrationConnectionGuard-CgFvoH9G.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-7Ka1N6pQ.js
https://ui.integration.app/assets/index-7Ka1N6pQ.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/Routes-CDWw4D3f.js
https://ui.integration.app/assets/Routes-CDWw4D3f.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-Dg8yQbmF.js
https://ui.integration.app/assets/index-Dg8yQbmF.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/ResetButton-B5vGAh3h.js
https://ui.integration.app/assets/ResetButton-B5vGAh3h.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/flow-instance-context-CJr_s6BC.js
https://ui.integration.app/assets/flow-instance-context-CJr_s6BC.js
[HTTP/3
200
0ms]
Show/hide message details.
GET
https://ui.integration.app/assets/index-BhMG5VAy.css
https://ui.integration.app/assets/index-BhMG5VAy.css
[HTTP/3
200
0ms]
Show/hide message details....
|
NULL
|
|
47285
|
NULL
|
0
|
2026-04-17T11:19:05.901113+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776424745901_m1.jpg...
|
Firefox
|
Auth Proxy — Work
|
True
|
docs.getmembrane.com/reference/auth-proxy
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Membrane
Membrane
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection
POST
Archive connection DEL
Archive connection
DEL
Connectors Hide subpages for Connectors
Connectors
Hide subpages for Connectors
Connector Types Hide subpages for Connector Types
Connector Types
Hide subpages for Connector Types
Client Credentials
Client Credentials
Membrane Token
Membrane Token
OAuth1...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"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":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"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":"Auth Proxy","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Auth Proxy","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":"Membrane","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Membrane","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,"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":"Open bookmarks (⌘B)","depth":6,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Jump to Content","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jump to Content","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Membrane Docs","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":" Docs","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Docs","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":" API Reference","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"API Reference","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search ⌘k","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Search","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Log In","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Log In","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"JUMP TO","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXHeading","text":"OVERVIEW","depth":11,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"OVERVIEW","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Authentication","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Authentication","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Element Selectors","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Element Selectors","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"API Errors","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"API Errors","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"WORKSPACE ELEMENTS","depth":11,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"WORKSPACE ELEMENTS","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connections Show subpages for Connections","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connections","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show subpages for Connections","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"List connections GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List connections","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Create connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Patch connection PATCH","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Patch connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PATCH","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Update connection PUT","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Update connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PUT","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Test connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Test connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Refresh connection credentials POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Refresh connection credentials","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection logs GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection logs","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection dependencies GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection dependencies","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Export connection GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Export connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Restore connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Restore connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Archive connection DEL","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Archive connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DEL","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connectors Hide subpages for Connectors","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connectors","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Hide subpages for Connectors","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"Connector Types Hide subpages for Connector Types","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connector Types","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Hide subpages for Connector Types","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"Client Credentials","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Client Credentials","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Membrane Token","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Membrane Token","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"OAuth1","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-1880352834673245291
|
-7454551344340616596
|
click
|
accessibility
|
NULL
|
Workers | Datadog
Developers | HubSpot
Developers Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Membrane
Membrane
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection
POST
Archive connection DEL
Archive connection
DEL
Connectors Hide subpages for Connectors
Connectors
Hide subpages for Connectors
Connector Types Hide subpages for Connector Types
Connector Types
Hide subpages for Connector Types
Client Credentials
Client Credentials
Membrane Token
Membrane Token
OAuth1...
|
47282
|
|
47288
|
NULL
|
0
|
2026-04-17T11:19:09.692253+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776424749692_m2.jpg...
|
NULL
|
NULL
|
True
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Firefox FileEoitViewHistory Bookmarks Profiles T Firefox FileEoitViewHistory Bookmarks Profiles Tools Window HelpDevelopers | HubSpotM'inbox (1,576) - lukas.kovalik@jiminM° 120216 is your HubSpot Log in CocFal CloudWatch | eu-west-1New Tabz Configure SSH access to multiple. fix-cache-for-business-processes4 [JY-20692] Issue with reconnectir(8) JiminnyAuth ProxyMembrahe-= docs.getmembrane.com/reference/auth-proxyMembraneM Docs<> API ReferenceJUMPTOOVERVIEWHutnenuicalionElement SelectorsAPl ErrorsWORKSPACE ELEMENISConnectionsConnectorsConnector TypesCent CredentialsMembraneconsole.getmemorane.com+ New TabC< 40 ll O SupportDaily - in 41m100% ( Fri 17 Apr 14:19:09=Q SearchLog InWORKSPACE ELEMENTS > CONNECTORS > CONNECTOR TYPESAuth Proxy•† Ask Al vAuth Proxy lets you use OAuth credentials provided by Membrane without registering your own OAuth app.You typically don't need to use this authentication type yourself, but you may find it in pre-builtconnectors.When using Auth Proxy, you will not have access to the connection credentials.Example connector definition:YAML# Connector definition: Use Membrane-provided OAuth credentials (no custom OAuth appпесасалtype: auth-proxyproxyKey: auth-proxy-key@ Updated 30 days ago< OAuth2Connector Functions →List connectorsGETOList public connectorsGet connectorCreate connectorPatch connectorDelete connectorDownload connectorUpload connectorImport connectorClone connectorGet connector versionsPublish connector versionUpdate connectorcompletelyGet a list of connector filesGet connector file by pathUpdate connector fileDelete connector fileDelete connector directoryExport connector as zip GETIfileExport connector as JSON GETDid this page help you? & Yes INo...
|
NULL
|
8253081986799999724
|
NULL
|
visual_change
|
ocr
|
NULL
|
Firefox FileEoitViewHistory Bookmarks Profiles T Firefox FileEoitViewHistory Bookmarks Profiles Tools Window HelpDevelopers | HubSpotM'inbox (1,576) - lukas.kovalik@jiminM° 120216 is your HubSpot Log in CocFal CloudWatch | eu-west-1New Tabz Configure SSH access to multiple. fix-cache-for-business-processes4 [JY-20692] Issue with reconnectir(8) JiminnyAuth ProxyMembrahe-= docs.getmembrane.com/reference/auth-proxyMembraneM Docs<> API ReferenceJUMPTOOVERVIEWHutnenuicalionElement SelectorsAPl ErrorsWORKSPACE ELEMENISConnectionsConnectorsConnector TypesCent CredentialsMembraneconsole.getmemorane.com+ New TabC< 40 ll O SupportDaily - in 41m100% ( Fri 17 Apr 14:19:09=Q SearchLog InWORKSPACE ELEMENTS > CONNECTORS > CONNECTOR TYPESAuth Proxy•† Ask Al vAuth Proxy lets you use OAuth credentials provided by Membrane without registering your own OAuth app.You typically don't need to use this authentication type yourself, but you may find it in pre-builtconnectors.When using Auth Proxy, you will not have access to the connection credentials.Example connector definition:YAML# Connector definition: Use Membrane-provided OAuth credentials (no custom OAuth appпесасалtype: auth-proxyproxyKey: auth-proxy-key@ Updated 30 days ago< OAuth2Connector Functions →List connectorsGETOList public connectorsGet connectorCreate connectorPatch connectorDelete connectorDownload connectorUpload connectorImport connectorClone connectorGet connector versionsPublish connector versionUpdate connectorcompletelyGet a list of connector filesGet connector file by pathUpdate connector fileDelete connector fileDelete connector directoryExport connector as zip GETIfileExport connector as JSON GETDid this page help you? & Yes INo...
|
47287
|
|
47309
|
NULL
|
0
|
2026-04-17T11:24:38.628802+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776425078628_m1.jpg...
|
Firefox
|
Auth Proxy — Work
|
True
|
docs.getmembrane.com/reference/auth-proxy
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny · Membrane
console.getmembrane.com
Workers Jiminny · Membrane
console.getmembrane.com
Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Jiminny · Membrane
Jiminny · Membrane
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection
POST
Archive connection DEL
Archive connection
DEL
Connectors Hide subpages for Connectors
Connectors
Hide subpages for Connectors
Connector Types Hide subpages for Connector Types
Connector Types
Hide subpages for Connector Types
Client Credentials
Client Credentials
Membrane Token
Membrane Token
OAuth1
OAuth1
OAuth2
OAuth2
Auth Proxy
Auth Proxy
Connector Functions Show subpages for Connector Functions
Connector Functions
Show subpages for Connector Functions
Get Credentials from Connection Parameters
Get Credentials from Connection Parameters
Make API Client
Make API Client
Refresh Credentials
Refresh Credentials
Test
Test
Disconnect
Disconnect
Universal Data Models
Universal Data Models
List connectors GET
List connectors
GET
List public connectors GET...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Jiminny · Membrane","depth":4,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"console.getmembrane.com","depth":4,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"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":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"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":"Auth Proxy","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Auth Proxy","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 · Membrane","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny · Membrane","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":"Open bookmarks (⌘B)","depth":6,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Jump to Content","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jump to Content","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Membrane Docs","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":" Docs","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Docs","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":" API Reference","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"API Reference","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search ⌘k","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Search","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Log In","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Log In","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"JUMP TO","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXHeading","text":"OVERVIEW","depth":11,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"OVERVIEW","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Authentication","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Authentication","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Element Selectors","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Element Selectors","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"API Errors","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"API Errors","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"WORKSPACE ELEMENTS","depth":11,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"WORKSPACE ELEMENTS","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connections Show subpages for Connections","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connections","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show subpages for Connections","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"List connections GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List connections","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Create connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Patch connection PATCH","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Patch connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PATCH","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Update connection PUT","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Update connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PUT","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Test connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Test connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Refresh connection credentials POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Refresh connection credentials","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection logs GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection logs","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection dependencies GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection dependencies","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Export connection GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Export connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Restore connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Restore connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Archive connection DEL","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Archive connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DEL","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connectors Hide subpages for Connectors","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connectors","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Hide subpages for Connectors","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"Connector Types Hide subpages for Connector Types","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connector Types","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Hide subpages for Connector Types","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"Client Credentials","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Client Credentials","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Membrane Token","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Membrane Token","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"OAuth1","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"OAuth1","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"OAuth2","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"OAuth2","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Auth Proxy","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Auth Proxy","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connector Functions Show subpages for Connector Functions","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connector Functions","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show subpages for Connector Functions","depth":16,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Get Credentials from Connection Parameters","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get Credentials from Connection Parameters","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Make API Client","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Make API Client","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Refresh Credentials","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Refresh Credentials","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Test","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Test","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Disconnect","depth":17,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Disconnect","depth":19,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Universal Data Models","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Universal Data Models","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"List connectors GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List connectors","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"List public connectors GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-5567825423644768547
|
1859033972171518540
|
idle
|
accessibility
|
NULL
|
Jiminny · Membrane
console.getmembrane.com
Workers Jiminny · Membrane
console.getmembrane.com
Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Jiminny · Membrane
Jiminny · Membrane
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection
POST
Archive connection DEL
Archive connection
DEL
Connectors Hide subpages for Connectors
Connectors
Hide subpages for Connectors
Connector Types Hide subpages for Connector Types
Connector Types
Hide subpages for Connector Types
Client Credentials
Client Credentials
Membrane Token
Membrane Token
OAuth1
OAuth1
OAuth2
OAuth2
Auth Proxy
Auth Proxy
Connector Functions Show subpages for Connector Functions
Connector Functions
Show subpages for Connector Functions
Get Credentials from Connection Parameters
Get Credentials from Connection Parameters
Make API Client
Make API Client
Refresh Credentials
Refresh Credentials
Test
Test
Disconnect
Disconnect
Universal Data Models
Universal Data Models
List connectors GET
List connectors
GET
List public connectors GET...
|
NULL
|
|
47310
|
NULL
|
0
|
2026-04-17T11:24:44.812934+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776425084812_m2.jpg...
|
Firefox
|
Auth Proxy — Work
|
True
|
docs.getmembrane.com/reference/auth-proxy
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny · Membrane
console.getmembrane.com
Workers Jiminny · Membrane
console.getmembrane.com
Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Jiminny · Membrane
Jiminny · Membrane
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection
POST
Archive connection DEL
Archive connection
DEL
Connectors Hide subpages for Connectors
Connectors
Hide subpages for Connectors
Connector Types Hide subpages for Connector Types
Connector Types
Hide subpages for Connector Types
Client Credentials
Client Credentials
Membrane Token
Membrane Token
OAuth1
OAuth1
OAuth2
OAuth2
Auth Proxy
Auth Proxy
Connector Functions Show subpages for Connector Functions
Connector Functions
Show subpages for Connector Functions
Get Credentials from Connection Parameters
Get Credentials from Connection Parameters
Make API Client
Make API Client
Refresh Credentials
Refresh Credentials
Test
Test
Disconnect
Disconnect
Universal Data Models
Universal Data Models
List connectors GET
List connectors
GET
List public connectors GET
List public connectors
GET
Get connector GET
Get connector
GET
Create connector POST
Create connector
POST
Patch connector PATCH
Patch connector
PATCH
Delete connector DEL
Delete connector
DEL
Download connector GET
Download connector
GET
Upload connector POST
Upload connector
POST
Import connector POST
Import connector
POST
Clone connector POST
Clone connector
POST
Get connector versions GET
Get connector versions
GET
Publish connector version POST
Publish connector version
POST
Update connector completely PUT
Update connector completely
PUT
Get a list of connector files GET
Get a list of connector files
GET
Get connector file by path GET
Get connector file by path
GET
Update connector file PUT
Update connector file
PUT
Delete connector file DEL
Delete connector file
DEL
Delete connector directory DEL
Delete connector directory
DEL
Export connector as zip file GET
Export connector as zip file
GET
Export connector as JSON GET
Export connector as JSON
GET
Integrations Show subpages for Integrations
Integrations
Show subpages for Integrations...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Jiminny · Membrane","depth":4,"bounds":{"left":0.09804688,"top":0.37430555,"width":0.04296875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"console.getmembrane.com","depth":4,"bounds":{"left":0.09804688,"top":0.38402778,"width":0.05546875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"bounds":{"left":0.00234375,"top":0.045138888,"width":0.0890625,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"bounds":{"left":0.0,"top":0.08263889,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"bounds":{"left":0.015625,"top":0.09236111,"width":0.04453125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.0,"top":0.11111111,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.015625,"top":0.12083333,"width":0.11445312,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.0,"top":0.13958333,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.015625,"top":0.14930555,"width":0.17734376,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"bounds":{"left":0.0,"top":0.16805555,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"bounds":{"left":0.015625,"top":0.17777778,"width":0.048828125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.19652778,"width":0.09375,"height":0.028472222},"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.015625,"top":0.20625,"width":0.017578125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"bounds":{"left":0.0,"top":0.225,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"bounds":{"left":0.015625,"top":0.23472223,"width":0.1515625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2534722,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"bounds":{"left":0.015625,"top":0.26319444,"width":0.17421874,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"bounds":{"left":0.0,"top":0.28194445,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"bounds":{"left":0.015625,"top":0.29166666,"width":0.09726562,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.0,"top":0.31041667,"width":0.09375,"height":0.028472222},"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.015625,"top":0.3201389,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Auth Proxy","depth":4,"bounds":{"left":0.0,"top":0.33888888,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Auth Proxy","depth":5,"bounds":{"left":0.015625,"top":0.34861112,"width":0.021875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.07890625,"top":0.34513888,"width":0.009375,"height":0.016666668},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Jiminny · Membrane","depth":4,"bounds":{"left":0.0,"top":0.3673611,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny · Membrane","depth":5,"bounds":{"left":0.015625,"top":0.37708333,"width":0.041015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.07890625,"top":0.37361112,"width":0.009375,"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.003125,"top":0.39722222,"width":0.08710937,"height":0.022222223},"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.003125,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.01640625,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.029296875,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.0421875,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.05546875,"top":0.97430557,"width":0.0125,"height":0.022222223},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Jump to Content","depth":11,"bounds":{"left":0.09765625,"top":0.052083332,"width":0.048828125,"height":0.020833334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jump to Content","depth":12,"bounds":{"left":0.1,"top":0.056944445,"width":0.044140626,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Membrane Docs","depth":11,"bounds":{"left":0.1015625,"top":0.048611112,"width":0.05078125,"height":0.027083334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":" Docs","depth":11,"bounds":{"left":0.16015625,"top":0.048611112,"width":0.02421875,"height":0.027083334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"bounds":{"left":0.16132812,"top":0.056944445,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Docs","depth":13,"bounds":{"left":0.16992188,"top":0.05625,"width":0.01328125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":" API Reference","depth":11,"bounds":{"left":0.1921875,"top":0.048611112,"width":0.048046876,"height":0.027083334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"bounds":{"left":0.19335938,"top":0.056944445,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"API Reference","depth":13,"bounds":{"left":0.20195313,"top":0.05625,"width":0.037109375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search ⌘k","depth":10,"bounds":{"left":0.90117186,"top":0.052083332,"width":0.05859375,"height":0.020833334},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":11,"bounds":{"left":0.903125,"top":0.056944445,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Search","depth":12,"bounds":{"left":0.91132814,"top":0.05625,"width":0.017578125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Log In","depth":10,"bounds":{"left":0.96367186,"top":0.052083332,"width":0.024609376,"height":0.020833334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Log In","depth":11,"bounds":{"left":0.96796876,"top":0.05625,"width":0.016015625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"JUMP TO","depth":10,"bounds":{"left":0.099609375,"top":0.097222224,"width":0.09765625,"height":0.020833334},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXHeading","text":"OVERVIEW","depth":11,"bounds":{"left":0.099609375,"top":0.14166667,"width":0.09765625,"height":0.011111111},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"OVERVIEW","depth":12,"bounds":{"left":0.103515625,"top":0.1423611,"width":0.02578125,"height":0.010416667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Authentication","depth":13,"bounds":{"left":0.099609375,"top":0.16041666,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Authentication","depth":15,"bounds":{"left":0.103515625,"top":0.16597222,"width":0.037109375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Element Selectors","depth":13,"bounds":{"left":0.099609375,"top":0.18333334,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Element Selectors","depth":15,"bounds":{"left":0.103515625,"top":0.18888889,"width":0.04609375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"API Errors","depth":13,"bounds":{"left":0.099609375,"top":0.20625,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"API Errors","depth":15,"bounds":{"left":0.103515625,"top":0.21180555,"width":0.025390625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"WORKSPACE ELEMENTS","depth":11,"bounds":{"left":0.099609375,"top":0.24930556,"width":0.09765625,"height":0.011111111},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"WORKSPACE ELEMENTS","depth":12,"bounds":{"left":0.103515625,"top":0.25,"width":0.058203124,"height":0.010416667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connections Show subpages for Connections","depth":13,"bounds":{"left":0.099609375,"top":0.26805556,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connections","depth":15,"bounds":{"left":0.103515625,"top":0.2736111,"width":0.03203125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show subpages for Connections","depth":14,"bounds":{"left":0.1890625,"top":0.2736111,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"List connections GET","depth":15,"bounds":{"left":0.103515625,"top":0.28541666,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List connections","depth":17,"bounds":{"left":0.107421875,"top":0.29097223,"width":0.041796874,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.2923611,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection GET","depth":15,"bounds":{"left":0.103515625,"top":0.30833334,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection","depth":17,"bounds":{"left":0.107421875,"top":0.31388888,"width":0.0390625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.31527779,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Create connection POST","depth":15,"bounds":{"left":0.103515625,"top":0.33125,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create connection","depth":17,"bounds":{"left":0.107421875,"top":0.33680555,"width":0.046875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.33819443,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Patch connection PATCH","depth":15,"bounds":{"left":0.103515625,"top":0.35416666,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Patch connection","depth":17,"bounds":{"left":0.107421875,"top":0.35972223,"width":0.04453125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PATCH","depth":16,"bounds":{"left":0.18085937,"top":0.3611111,"width":0.01328125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Update connection PUT","depth":15,"bounds":{"left":0.103515625,"top":0.37708333,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Update connection","depth":17,"bounds":{"left":0.107421875,"top":0.3826389,"width":0.0484375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PUT","depth":16,"bounds":{"left":0.18320313,"top":0.38402778,"width":0.00859375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Test connection POST","depth":15,"bounds":{"left":0.103515625,"top":0.4,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Test connection","depth":17,"bounds":{"left":0.107421875,"top":0.40555555,"width":0.040625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.40694445,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Refresh connection credentials POST","depth":15,"bounds":{"left":0.103515625,"top":0.42291668,"width":0.09375,"height":0.036111113},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Refresh connection credentials","depth":17,"bounds":{"left":0.107421875,"top":0.42847222,"width":0.049609374,"height":0.025694445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.4298611,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection logs GET","depth":15,"bounds":{"left":0.103515625,"top":0.45972222,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection logs","depth":17,"bounds":{"left":0.107421875,"top":0.4652778,"width":0.051171876,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.46666667,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection dependencies GET","depth":15,"bounds":{"left":0.103515625,"top":0.4826389,"width":0.09375,"height":0.036111113},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection dependencies","depth":17,"bounds":{"left":0.107421875,"top":0.48819444,"width":0.0390625,"height":0.025694445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.48958334,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Export connection GET","depth":15,"bounds":{"left":0.103515625,"top":0.51944447,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Export connection","depth":17,"bounds":{"left":0.107421875,"top":0.525,"width":0.046484374,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.5263889,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Restore connection POST","depth":15,"bounds":{"left":0.103515625,"top":0.54236114,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Restore connection","depth":17,"bounds":{"left":0.107421875,"top":0.54791665,"width":0.049609374,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.54930556,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Archive connection DEL","depth":15,"bounds":{"left":0.103515625,"top":0.56527776,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Archive connection","depth":17,"bounds":{"left":0.107421875,"top":0.5708333,"width":0.04921875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DEL","depth":16,"bounds":{"left":0.18359375,"top":0.57222223,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connectors Hide subpages for Connectors","depth":13,"bounds":{"left":0.099609375,"top":0.29097223,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connectors","depth":15,"bounds":{"left":0.103515625,"top":0.29652777,"width":0.0296875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Hide subpages for Connectors","depth":14,"bounds":{"left":0.1890625,"top":0.29652777,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"Connector Types Hide subpages for Connector Types","depth":15,"bounds":{"left":0.103515625,"top":0.31388888,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connector Types","depth":17,"bounds":{"left":0.107421875,"top":0.31944445,"width":0.04375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Hide subpages for Connector Types","depth":16,"bounds":{"left":0.1890625,"top":0.31944445,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"Client Credentials","depth":17,"bounds":{"left":0.107421875,"top":0.33680555,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Client Credentials","depth":19,"bounds":{"left":0.111328125,"top":0.34236112,"width":0.0453125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Membrane Token","depth":17,"bounds":{"left":0.107421875,"top":0.35972223,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Membrane Token","depth":19,"bounds":{"left":0.111328125,"top":0.36527777,"width":0.044140626,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"OAuth1","depth":17,"bounds":{"left":0.107421875,"top":0.3826389,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"OAuth1","depth":19,"bounds":{"left":0.111328125,"top":0.38819444,"width":0.018359374,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"OAuth2","depth":17,"bounds":{"left":0.107421875,"top":0.40555555,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"OAuth2","depth":19,"bounds":{"left":0.111328125,"top":0.41111112,"width":0.019140625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Auth Proxy","depth":17,"bounds":{"left":0.107421875,"top":0.42847222,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Auth Proxy","depth":19,"bounds":{"left":0.111328125,"top":0.4340278,"width":0.027734375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connector Functions Show subpages for Connector Functions","depth":15,"bounds":{"left":0.103515625,"top":0.45277777,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connector Functions","depth":17,"bounds":{"left":0.107421875,"top":0.45833334,"width":0.053515624,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show subpages for Connector Functions","depth":16,"bounds":{"left":0.1890625,"top":0.45833334,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Get Credentials from Connection Parameters","depth":17,"bounds":{"left":0.107421875,"top":0.47013888,"width":0.08984375,"height":0.036111113},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get Credentials from Connection Parameters","depth":19,"bounds":{"left":0.111328125,"top":0.47569445,"width":0.083984375,"height":0.025694445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Make API Client","depth":17,"bounds":{"left":0.107421875,"top":0.5069444,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Make API Client","depth":19,"bounds":{"left":0.111328125,"top":0.5125,"width":0.040234376,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Refresh Credentials","depth":17,"bounds":{"left":0.107421875,"top":0.5298611,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Refresh Credentials","depth":19,"bounds":{"left":0.111328125,"top":0.53541666,"width":0.050390624,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Test","depth":17,"bounds":{"left":0.107421875,"top":0.55277777,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Test","depth":19,"bounds":{"left":0.111328125,"top":0.55833334,"width":0.0109375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Disconnect","depth":17,"bounds":{"left":0.107421875,"top":0.57569444,"width":0.08984375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Disconnect","depth":19,"bounds":{"left":0.111328125,"top":0.58125,"width":0.02890625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Universal Data Models","depth":15,"bounds":{"left":0.103515625,"top":0.47569445,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Universal Data Models","depth":17,"bounds":{"left":0.107421875,"top":0.48125,"width":0.05703125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"List connectors GET","depth":15,"bounds":{"left":0.103515625,"top":0.49861112,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List connectors","depth":17,"bounds":{"left":0.107421875,"top":0.50416666,"width":0.039453126,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.50555557,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"List public connectors GET","depth":15,"bounds":{"left":0.103515625,"top":0.52152777,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List public connectors","depth":17,"bounds":{"left":0.107421875,"top":0.52708334,"width":0.056640625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.52847224,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connector GET","depth":15,"bounds":{"left":0.103515625,"top":0.54444444,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connector","depth":17,"bounds":{"left":0.107421875,"top":0.55,"width":0.03671875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.55138886,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Create connector POST","depth":15,"bounds":{"left":0.103515625,"top":0.5673611,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create connector","depth":17,"bounds":{"left":0.107421875,"top":0.5729167,"width":0.04453125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.57430553,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Patch connector PATCH","depth":15,"bounds":{"left":0.103515625,"top":0.5902778,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Patch connector","depth":17,"bounds":{"left":0.107421875,"top":0.59583336,"width":0.041796874,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PATCH","depth":16,"bounds":{"left":0.18085937,"top":0.5972222,"width":0.01328125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Delete connector DEL","depth":15,"bounds":{"left":0.103515625,"top":0.61319447,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Delete connector","depth":17,"bounds":{"left":0.107421875,"top":0.61875,"width":0.044140626,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DEL","depth":16,"bounds":{"left":0.18359375,"top":0.6201389,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Download connector GET","depth":15,"bounds":{"left":0.103515625,"top":0.63611114,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Download connector","depth":17,"bounds":{"left":0.107421875,"top":0.64166665,"width":0.052734375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.64305556,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Upload connector POST","depth":15,"bounds":{"left":0.103515625,"top":0.65902776,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Upload connector","depth":17,"bounds":{"left":0.107421875,"top":0.6645833,"width":0.045703124,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.66597223,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Import connector POST","depth":15,"bounds":{"left":0.103515625,"top":0.68194443,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Import connector","depth":17,"bounds":{"left":0.107421875,"top":0.6875,"width":0.044140626,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.6888889,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Clone connector POST","depth":15,"bounds":{"left":0.103515625,"top":0.7048611,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Clone connector","depth":17,"bounds":{"left":0.107421875,"top":0.7104167,"width":0.0421875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.7118056,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connector versions GET","depth":15,"bounds":{"left":0.103515625,"top":0.7277778,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connector versions","depth":17,"bounds":{"left":0.107421875,"top":0.73333335,"width":0.059375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.7347222,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Publish connector version POST","depth":15,"bounds":{"left":0.103515625,"top":0.75069445,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Publish connector version","depth":17,"bounds":{"left":0.107421875,"top":0.75625,"width":0.06601562,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.7576389,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Update connector completely PUT","depth":15,"bounds":{"left":0.103515625,"top":0.7736111,"width":0.09375,"height":0.036111113},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Update connector completely","depth":17,"bounds":{"left":0.107421875,"top":0.77916664,"width":0.04609375,"height":0.025694445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PUT","depth":16,"bounds":{"left":0.18320313,"top":0.78055555,"width":0.00859375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get a list of connector files GET","depth":15,"bounds":{"left":0.103515625,"top":0.81041664,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get a list of connector files","depth":17,"bounds":{"left":0.107421875,"top":0.8159722,"width":0.06875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.8173611,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connector file by path GET","depth":15,"bounds":{"left":0.103515625,"top":0.8333333,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connector file by path","depth":17,"bounds":{"left":0.107421875,"top":0.8388889,"width":0.06640625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.8402778,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Update connector file PUT","depth":15,"bounds":{"left":0.103515625,"top":0.85625,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Update connector file","depth":17,"bounds":{"left":0.107421875,"top":0.86180556,"width":0.05546875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PUT","depth":16,"bounds":{"left":0.18320313,"top":0.86319447,"width":0.00859375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Delete connector file DEL","depth":15,"bounds":{"left":0.103515625,"top":0.87916666,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Delete connector file","depth":17,"bounds":{"left":0.107421875,"top":0.88472223,"width":0.053125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DEL","depth":16,"bounds":{"left":0.18359375,"top":0.88611114,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Delete connector directory DEL","depth":15,"bounds":{"left":0.103515625,"top":0.90208334,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Delete connector directory","depth":17,"bounds":{"left":0.107421875,"top":0.9076389,"width":0.068359375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"DEL","depth":16,"bounds":{"left":0.18359375,"top":0.90902776,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Export connector as zip file GET","depth":15,"bounds":{"left":0.103515625,"top":0.925,"width":0.09375,"height":0.036111113},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Export connector as zip file","depth":17,"bounds":{"left":0.107421875,"top":0.9305556,"width":0.060546875,"height":0.025694445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.93194443,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Export connector as JSON GET","depth":15,"bounds":{"left":0.103515625,"top":0.9618056,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Export connector as JSON","depth":17,"bounds":{"left":0.107421875,"top":0.9673611,"width":0.06757812,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.96875,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Integrations Show subpages for Integrations","depth":13,"bounds":{"left":0.099609375,"top":0.9861111,"width":0.09765625,"height":0.0138888955},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Integrations","depth":15,"bounds":{"left":0.103515625,"top":0.9916667,"width":0.03046875,"height":0.008333325},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show subpages for Integrations","depth":14,"bounds":{"left":0.1890625,"top":0.9916667,"width":0.00625,"height":0.008333325},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2946507518247493010
|
-446809511585367476
|
idle
|
accessibility
|
NULL
|
Jiminny · Membrane
console.getmembrane.com
Workers Jiminny · Membrane
console.getmembrane.com
Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Jiminny · Membrane
Jiminny · Membrane
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection
POST
Archive connection DEL
Archive connection
DEL
Connectors Hide subpages for Connectors
Connectors
Hide subpages for Connectors
Connector Types Hide subpages for Connector Types
Connector Types
Hide subpages for Connector Types
Client Credentials
Client Credentials
Membrane Token
Membrane Token
OAuth1
OAuth1
OAuth2
OAuth2
Auth Proxy
Auth Proxy
Connector Functions Show subpages for Connector Functions
Connector Functions
Show subpages for Connector Functions
Get Credentials from Connection Parameters
Get Credentials from Connection Parameters
Make API Client
Make API Client
Refresh Credentials
Refresh Credentials
Test
Test
Disconnect
Disconnect
Universal Data Models
Universal Data Models
List connectors GET
List connectors
GET
List public connectors GET
List public connectors
GET
Get connector GET
Get connector
GET
Create connector POST
Create connector
POST
Patch connector PATCH
Patch connector
PATCH
Delete connector DEL
Delete connector
DEL
Download connector GET
Download connector
GET
Upload connector POST
Upload connector
POST
Import connector POST
Import connector
POST
Clone connector POST
Clone connector
POST
Get connector versions GET
Get connector versions
GET
Publish connector version POST
Publish connector version
POST
Update connector completely PUT
Update connector completely
PUT
Get a list of connector files GET
Get a list of connector files
GET
Get connector file by path GET
Get connector file by path
GET
Update connector file PUT
Update connector file
PUT
Delete connector file DEL
Delete connector file
DEL
Delete connector directory DEL
Delete connector directory
DEL
Export connector as zip file GET
Export connector as zip file
GET
Export connector as JSON GET
Export connector as JSON
GET
Integrations Show subpages for Integrations
Integrations
Show subpages for Integrations...
|
NULL
|
|
47327
|
NULL
|
0
|
2026-04-17T11:29:10.966876+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776425350966_m1.jpg...
|
Firefox
|
Auth Proxy — Work
|
True
|
docs.getmembrane.com/reference/auth-proxy
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny · Membrane
console.getmembrane.com
Workers Jiminny · Membrane
console.getmembrane.com
Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Jiminny · Membrane
Jiminny · Membrane
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Jiminny · Membrane","depth":4,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"console.getmembrane.com","depth":4,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"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":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"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":"Auth Proxy","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Auth Proxy","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 · Membrane","depth":4,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny · Membrane","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":"Open bookmarks (⌘B)","depth":6,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Jump to Content","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jump to Content","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Membrane Docs","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":" Docs","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Docs","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":" API Reference","depth":11,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"API Reference","depth":13,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search ⌘k","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Search","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Log In","depth":10,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Log In","depth":11,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"JUMP TO","depth":10,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXHeading","text":"OVERVIEW","depth":11,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"OVERVIEW","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Authentication","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Authentication","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Element Selectors","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Element Selectors","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"API Errors","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"API Errors","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"WORKSPACE ELEMENTS","depth":11,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"WORKSPACE ELEMENTS","depth":12,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connections Show subpages for Connections","depth":13,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connections","depth":15,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show subpages for Connections","depth":14,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"List connections GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List connections","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Create connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Patch connection PATCH","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Patch connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PATCH","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Update connection PUT","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Update connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PUT","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Test connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Test connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Refresh connection credentials POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Refresh connection credentials","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection logs GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection logs","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection dependencies GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection dependencies","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Export connection GET","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Export connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Restore connection POST","depth":15,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Restore connection","depth":17,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
4438693934993446109
|
6398556292618697292
|
idle
|
accessibility
|
NULL
|
Jiminny · Membrane
console.getmembrane.com
Workers Jiminny · Membrane
console.getmembrane.com
Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Jiminny · Membrane
Jiminny · Membrane
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection...
|
47325
|
|
47328
|
NULL
|
0
|
2026-04-17T11:29:18.631308+00:00
|
/Users/lukas/.screenpipe/data/data/2026-04-17/1776 /Users/lukas/.screenpipe/data/data/2026-04-17/1776425358631_m2.jpg...
|
Firefox
|
Auth Proxy — Work
|
True
|
docs.getmembrane.com/reference/auth-proxy
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny · Membrane
console.getmembrane.com
Workers Jiminny · Membrane
console.getmembrane.com
Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Jiminny · Membrane
Jiminny · Membrane
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection
POST
Archive connection DEL...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Jiminny · Membrane","depth":4,"bounds":{"left":0.09804688,"top":0.37430555,"width":0.04296875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"console.getmembrane.com","depth":4,"bounds":{"left":0.09804688,"top":0.38402778,"width":0.05546875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Workers | Datadog","depth":4,"bounds":{"left":0.00234375,"top":0.045138888,"width":0.0890625,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Developers | HubSpot","depth":4,"bounds":{"left":0.0,"top":0.08263889,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Developers | HubSpot","depth":5,"bounds":{"left":0.015625,"top":0.09236111,"width":0.04453125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.0,"top":0.11111111,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Inbox (1,576) - lukas.kovalik@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.015625,"top":0.12083333,"width":0.11445312,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":4,"bounds":{"left":0.0,"top":0.13958333,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"120216 is your HubSpot Log In Code - integration-account@jiminny.com - Jiminny Mail","depth":5,"bounds":{"left":0.015625,"top":0.14930555,"width":0.17734376,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"bounds":{"left":0.0,"top":0.16805555,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"bounds":{"left":0.015625,"top":0.17777778,"width":0.048828125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"New Tab","depth":4,"bounds":{"left":0.0,"top":0.19652778,"width":0.09375,"height":0.028472222},"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.015625,"top":0.20625,"width":0.017578125,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":4,"bounds":{"left":0.0,"top":0.225,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Configure SSH access to multiple environment - Engineering - Confluence","depth":5,"bounds":{"left":0.015625,"top":0.23472223,"width":0.1515625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2534722,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app","depth":5,"bounds":{"left":0.015625,"top":0.26319444,"width":0.17421874,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":4,"bounds":{"left":0.0,"top":0.28194445,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20692] Issue with reconnecting Zoho - Jira","depth":5,"bounds":{"left":0.015625,"top":0.29166666,"width":0.09726562,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.0,"top":0.31041667,"width":0.09375,"height":0.028472222},"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.015625,"top":0.3201389,"width":0.015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Auth Proxy","depth":4,"bounds":{"left":0.0,"top":0.33888888,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Auth Proxy","depth":5,"bounds":{"left":0.015625,"top":0.34861112,"width":0.021875,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.07890625,"top":0.34513888,"width":0.009375,"height":0.016666668},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXRadioButton","text":"Jiminny · Membrane","depth":4,"bounds":{"left":0.0,"top":0.3673611,"width":0.09375,"height":0.028472222},"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny · Membrane","depth":5,"bounds":{"left":0.015625,"top":0.37708333,"width":0.041015625,"height":0.009722223},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.07890625,"top":0.37361112,"width":0.009375,"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.003125,"top":0.39722222,"width":0.08710937,"height":0.022222223},"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.003125,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.01640625,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.029296875,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.0421875,"top":0.97430557,"width":0.0125,"height":0.022222223},"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.05546875,"top":0.97430557,"width":0.0125,"height":0.022222223},"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":"Jump to Content","depth":11,"bounds":{"left":0.09765625,"top":0.052083332,"width":0.048828125,"height":0.020833334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jump to Content","depth":12,"bounds":{"left":0.1,"top":0.056944445,"width":0.044140626,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Membrane Docs","depth":11,"bounds":{"left":0.1015625,"top":0.048611112,"width":0.05078125,"height":0.027083334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXLink","text":" Docs","depth":11,"bounds":{"left":0.16015625,"top":0.048611112,"width":0.02421875,"height":0.027083334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"bounds":{"left":0.16132812,"top":0.056944445,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Docs","depth":13,"bounds":{"left":0.16992188,"top":0.05625,"width":0.01328125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":" API Reference","depth":11,"bounds":{"left":0.1921875,"top":0.048611112,"width":0.048046876,"height":0.027083334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":13,"bounds":{"left":0.19335938,"top":0.056944445,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"API Reference","depth":13,"bounds":{"left":0.20195313,"top":0.05625,"width":0.037109375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Search ⌘k","depth":10,"bounds":{"left":0.90117186,"top":0.052083332,"width":0.05859375,"height":0.020833334},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"","depth":11,"bounds":{"left":0.903125,"top":0.056944445,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Search","depth":12,"bounds":{"left":0.91132814,"top":0.05625,"width":0.017578125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Log In","depth":10,"bounds":{"left":0.96367186,"top":0.052083332,"width":0.024609376,"height":0.020833334},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Log In","depth":11,"bounds":{"left":0.96796876,"top":0.05625,"width":0.016015625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"JUMP TO","depth":10,"bounds":{"left":0.099609375,"top":0.097222224,"width":0.09765625,"height":0.020833334},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":true,"is_selected":false},{"role":"AXHeading","text":"OVERVIEW","depth":11,"bounds":{"left":0.099609375,"top":0.14166667,"width":0.09765625,"height":0.011111111},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"OVERVIEW","depth":12,"bounds":{"left":0.103515625,"top":0.1423611,"width":0.02578125,"height":0.010416667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Authentication","depth":13,"bounds":{"left":0.099609375,"top":0.16041666,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Authentication","depth":15,"bounds":{"left":0.103515625,"top":0.16597222,"width":0.037109375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Element Selectors","depth":13,"bounds":{"left":0.099609375,"top":0.18333334,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Element Selectors","depth":15,"bounds":{"left":0.103515625,"top":0.18888889,"width":0.04609375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"API Errors","depth":13,"bounds":{"left":0.099609375,"top":0.20625,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"API Errors","depth":15,"bounds":{"left":0.103515625,"top":0.21180555,"width":0.025390625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"WORKSPACE ELEMENTS","depth":11,"bounds":{"left":0.099609375,"top":0.24930556,"width":0.09765625,"height":0.011111111},"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"WORKSPACE ELEMENTS","depth":12,"bounds":{"left":0.103515625,"top":0.25,"width":0.058203124,"height":0.010416667},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Connections Show subpages for Connections","depth":13,"bounds":{"left":0.099609375,"top":0.26805556,"width":0.09765625,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Connections","depth":15,"bounds":{"left":0.103515625,"top":0.2736111,"width":0.03203125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Show subpages for Connections","depth":14,"bounds":{"left":0.1890625,"top":0.2736111,"width":0.00625,"height":0.011111111},"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"List connections GET","depth":15,"bounds":{"left":0.103515625,"top":0.28541666,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"List connections","depth":17,"bounds":{"left":0.107421875,"top":0.29097223,"width":0.041796874,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.2923611,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection GET","depth":15,"bounds":{"left":0.103515625,"top":0.30833334,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection","depth":17,"bounds":{"left":0.107421875,"top":0.31388888,"width":0.0390625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.31527779,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Create connection POST","depth":15,"bounds":{"left":0.103515625,"top":0.33125,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create connection","depth":17,"bounds":{"left":0.107421875,"top":0.33680555,"width":0.046875,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.33819443,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Patch connection PATCH","depth":15,"bounds":{"left":0.103515625,"top":0.35416666,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Patch connection","depth":17,"bounds":{"left":0.107421875,"top":0.35972223,"width":0.04453125,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PATCH","depth":16,"bounds":{"left":0.18085937,"top":0.3611111,"width":0.01328125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Update connection PUT","depth":15,"bounds":{"left":0.103515625,"top":0.37708333,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Update connection","depth":17,"bounds":{"left":0.107421875,"top":0.3826389,"width":0.0484375,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"PUT","depth":16,"bounds":{"left":0.18320313,"top":0.38402778,"width":0.00859375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Test connection POST","depth":15,"bounds":{"left":0.103515625,"top":0.4,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Test connection","depth":17,"bounds":{"left":0.107421875,"top":0.40555555,"width":0.040625,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.40694445,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Refresh connection credentials POST","depth":15,"bounds":{"left":0.103515625,"top":0.42291668,"width":0.09375,"height":0.036111113},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Refresh connection credentials","depth":17,"bounds":{"left":0.107421875,"top":0.42847222,"width":0.049609374,"height":0.025694445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.4298611,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection logs GET","depth":15,"bounds":{"left":0.103515625,"top":0.45972222,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection logs","depth":17,"bounds":{"left":0.107421875,"top":0.4652778,"width":0.051171876,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.46666667,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Get connection dependencies GET","depth":15,"bounds":{"left":0.103515625,"top":0.4826389,"width":0.09375,"height":0.036111113},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Get connection dependencies","depth":17,"bounds":{"left":0.107421875,"top":0.48819444,"width":0.0390625,"height":0.025694445},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.48958334,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Export connection GET","depth":15,"bounds":{"left":0.103515625,"top":0.51944447,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Export connection","depth":17,"bounds":{"left":0.107421875,"top":0.525,"width":0.046484374,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"GET","depth":16,"bounds":{"left":0.18359375,"top":0.5263889,"width":0.0078125,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Restore connection POST","depth":15,"bounds":{"left":0.103515625,"top":0.54236114,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Restore connection","depth":17,"bounds":{"left":0.107421875,"top":0.54791665,"width":0.049609374,"height":0.011805556},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"POST","depth":16,"bounds":{"left":0.18203124,"top":0.54930556,"width":0.0109375,"height":0.009027778},"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Archive connection DEL","depth":15,"bounds":{"left":0.103515625,"top":0.56527776,"width":0.09375,"height":0.022222223},"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false}]...
|
-6410721714148244440
|
-2824534269259367860
|
idle
|
accessibility
|
NULL
|
Jiminny · Membrane
console.getmembrane.com
Workers Jiminny · Membrane
console.getmembrane.com
Workers | Datadog
Developers | HubSpot
Developers | HubSpot
Inbox (1,576) - [EMAIL] - Jiminny Mail
Inbox (1,576) - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
120216 is your HubSpot Log In Code - [EMAIL] - Jiminny Mail
CloudWatch | eu-west-1
CloudWatch | eu-west-1
New Tab
New Tab
Configure SSH access to multiple environment - Engineering - Confluence
Configure SSH access to multiple environment - Engineering - Confluence
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
fix-cache-for-business-processes by ilian-jiminny · Pull Request #11985 · jiminny/app
[JY-20692] Issue with reconnecting Zoho - Jira
[JY-20692] Issue with reconnecting Zoho - Jira
Jiminny
Jiminny
Auth Proxy
Auth Proxy
Close tab
Jiminny · Membrane
Jiminny · Membrane
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Jump to Content
Jump to Content
Membrane Docs
Docs
Docs
API Reference
API Reference
Search ⌘k
Search
Log In
Log In
JUMP TO
OVERVIEW
OVERVIEW
Authentication
Authentication
Element Selectors
Element Selectors
API Errors
API Errors
WORKSPACE ELEMENTS
WORKSPACE ELEMENTS
Connections Show subpages for Connections
Connections
Show subpages for Connections
List connections GET
List connections
GET
Get connection GET
Get connection
GET
Create connection POST
Create connection
POST
Patch connection PATCH
Patch connection
PATCH
Update connection PUT
Update connection
PUT
Test connection POST
Test connection
POST
Refresh connection credentials POST
Refresh connection credentials
POST
Get connection logs GET
Get connection logs
GET
Get connection dependencies GET
Get connection dependencies
GET
Export connection GET
Export connection
GET
Restore connection POST
Restore connection
POST
Archive connection DEL...
|
47326
|