|
9271
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20820 es reindex stream model hydration by Vasi</tabTitle>” with “<selection>@@ -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This selection contains unit tests for the
LoadDocumentsAction
class, specifically focusing on the
loadDocuments
method. The tests verify how the system hydrates search engine document models from database entities, handling updates, deletions, and exceptions.
Core Functionality Tested
Core Functionality Tested
The tests use a mocked
EntityQueryBuilder
and anonymous
Searchable
classes to simulate different hydration scenarios:
Document Updates:
Verifies that active entities are correctly transformed into
Document
objects containing the expected ID and data (indexable attributes).
Document Deletions:
Ensures that entities flagged as
isDeleted
are processed for removal from the search index.
Mixed Results:
Validates the ability to process a collection containing both updateable and deletable entities in a single stream.
Empty Result Sets:
Confirms the system handles empty entity lists gracefully without errors.
Error Handling & Resilience
Error Handling & Resilience
Exception Catching:
A specific test case (
testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes
) ensures that if a single entity fails to hydrate due to an exception, the system:
Logs the error to
Sentry
using a
SyncActivityException
.
Continues processing other valid entities in the stream rather than failing the entire batch.
Technical Implementation Details
Technical Implementation Details
Mocking Strategy:
Uses
createMock
for dependencies and a custom
createEntityMock
helper to generate anonymous classes implementing the
Searchable
interface.
Assertions:
Utilizes PHPUnit assertions like
assertEquals
,
assertInstanceOf
, and
count
to verify the state of the returned result object.
Iterator Usage:
The
createQueryBuilderMock
uses an
ArrayIterator
to simulate a database cursor, reflecting the streaming nature of the hydration process.
Good response
Bad response
Redo
Share & export
Copy
Show more options
Enter a prompt for Gemini
encrypted
Enter a prompt for Gemini
encrypted
Open upload file menu
Tools
Open mode picker
Pro
Microphone
Send message
Your Jiminny chats aren’t used to improve our models. Gemini is AI. It can make mistakes, so double check it.
Your privacy & Gemini Opens in a new window
Your privacy & Gemini
Opens in a new window
Summarize page
Summarize page
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (33)
Pull requests
(
33
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (2)
Security and quality
(
2
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
Jy 20820 es reindex stream model hydration #12059 Edit title
Jy 20820 es reindex stream model hydration
#
12059
Edit title
Preview
Preview
Awaiting approval
Awaiting approval
Code
Code
Open
Vasil-Jiminny
Vasil-Jiminny
wants to merge 35 commits into
master
master
from
JY-20820-es-reindex-stream-model-hydration
JY-20820-es-reindex-stream-model-hydration
Copy head branch name to clipboard
Lines changed: 363 additions & 140 deletions
Conversation (8)
Conversation
(
8
)
Commits (35)
Commits
(
35
)
Checks (3)
Checks
(
3
)
Files changed (12)
Files changed
(
12
)
Pull Request Toolbar
Pull Request Toolbar
Collapse file tree
All commits
All commits
0 of 12 files viewed
Submit review
Submit
review
Open diff view settings
Open overview panel
Open comments panel
(
0
)
Filter files…
Filter options
File tree
File tree
app
Component
ElasticSearch/Contract
Searchable.php
Searchable.php
ES
Processor
Actions
LoadDocumentsAction.php
Traits
SkipActivityTrait.php
EntityQueryBuilder.php
EntityQueryBuilder.php
Repositories
EsResetActivityRepository.php
EsResetActivityRepository.php
ElasticSearchDocumentPartialUpdater.php
ElasticSearchDocumentPartialUpdater.php
Console/Commands/Elasticsearch
AsyncUpdateEsActivities.php
AsyncUpdateEsActivities.php
AsyncUpdateEsEntities.php
AsyncUpdateEsEntities.php...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9271
|
|
9270
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20820 es reindex stream model hydration by Vasi</tabTitle>” with “<selection>@@ -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This selection contains unit tests for the
LoadDocumentsAction
class, specifically focusing on the
loadDocuments
method. The tests verify how the system hydrates search engine document models from database entities, handling updates, deletions, and exceptions.
Core Functionality Tested
Core Functionality Tested
The tests use a mocked
EntityQueryBuilder
and anonymous
Searchable
classes to simulate different hydration scenarios:
Document Updates:
Verifies that active entities are correctly transformed into
Document...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
NULL
|
9270
|
|
9269
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20820 es reindex stream model hydration by Vasi</tabTitle>” with “<selection>@@ -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This selection contains unit tests for the
LoadDocumentsAction
class, specifically focusing on the
loadDocuments
method. The tests verify how the system hydrates search engine document models from database entities, handling updates, deletions, and exceptions.
Core Functionality Tested
Core Functionality Tested
The tests use a mocked
EntityQueryBuilder
and anonymous
Searchable
classes to simulate different hydration scenarios:
Document Updates:
Verifies that active entities are correctly transformed into
Document
objects containing the expected ID and data (indexable attributes).
Document Deletions:
Ensures that entities flagged as
isDeleted
are processed for removal from the search index.
Mixed Results:
Validates the ability to process a collection containing both updateable and deletable entities in a single stream.
Empty Result Sets:
Confirms the system handles empty entity lists gracefully without errors.
Error Handling & Resilience
Error Handling & Resilience
Exception Catching:
A specific test case (
testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes
) ensures that if a single entity fails to hydrate due to an exception, the system:
Logs the error to
Sentry
using a
SyncActivityException
.
Continues processing other valid entities in the stream rather than failing the entire batch.
Technical Implementation Details
Technical Implementation Details
Mocking Strategy:
Uses
createMock
for dependencies and a custom
createEntityMock
helper to generate anonymous classes implementing the
Searchable
interface.
Assertions:
Utilizes PHPUnit assertions like
assertEquals
,
assertInstanceOf
, and
count
to verify the state of the returned result object.
Iterator Usage:
The
createQueryBuilderMock
uses an
ArrayIterator
to simulate a database cursor, reflecting the streaming nature of the hydration process.
Good response
Bad response
Redo
Share & export...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9269
|
|
9268
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)
1. Fix the N+1 Query Risk (Eager Loading)
Your
checkCategoryId
and
checkStageId
methods call
$activity->getCategory()
and
$activity->getStage()
. If these relationships aren't eager-loaded, Laravel will execute a new database query for every check inside the loop.
Suggestion:
Add
category
and
stage...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9268
|
|
9267
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9267
|
|
9266
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9266
|
|
9265
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
....
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9265
|
|
9264
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)
1. Fix the N+1 Query Risk (Eager Loading)
Your
checkCategoryId
and
checkStageId
methods call
$activity->getCategory()
and
$activity->getStage()
. If these relationships aren't eager-loaded, Laravel will execute a new database query for every check inside the loop.
Suggestion:
Add
category
and
stage
to the initial eager-load array.
PHP
Download code
Copy code
// Before (Line 38)
$activity
= Activity::with([
'user.group'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
// After
$activity
= Activity::with([
'user.group'
,
'category'
,
'stage'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
2. Drastically Reduce Memory Usage
2. Drastically Reduce Memory Usage
Currently, you are hydrating full Eloquent
User
models into memory just to extract their IDs. If a team has hundreds or thousands of users, this will spike memory usage and potentially crash the worker.
Suggestion:
Use the relationship query builder to perform the
pluck
directly at the database level, bypassing Eloquent model hydration entirely.
PHP
Download code
Copy code
// Before (Lines 39-41)
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
// After (No need to eager load 'users' on the Team model anymore)
$team
= Team::findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users()->pluck(
'users.id'
)->toArray();
// Note the `()` after users
3. Harden Type Comparisons
3. Harden Type Comparisons
Since Elasticsearch and databases often return IDs or filter values as strings, strict comparison (
===
) against an Eloquent model's attribute (which might be cast to an integer) can cause false negatives.
Suggestion:
Cast your values before comparison, or use loose equality (
==
) if you are absolutely certain of the payload structure.
PHP
Download code
Copy code
// Example for checkChannelId (Lines 132-135)
private
function
checkChannelId
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
// Cast both to string to ensure safe comparison
return
(
string
)
$filter
->getFilterValue() === (
string
)
$activity
->getType() ?
null
:
false
;
}
// Example for checkProviderId
private
function
checkProviderId
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
return
(
string
)
$filter
->getFilterValue() === (
string
)
$activity
->getProvider() ?
null
:
false
;
}
4. Optimize Date Parsing (Micro-Optimization)
4. Optimize Date Parsing (Micro-Optimization)
If the same date filter applies to multiple searches, you are instantiating Carbon objects repeatedly inside the loop.
Suggestion:
While Carbon is relatively fast, you can extract the date parsing out of the specific check and cache it in the job's state if you anticipate high volumes of searches. Alternatively, just ensure the filter value is valid before parsing:
PHP
Download code
Copy code
private
function
checkStartDate
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
$startTime
=
$activity
->getActualStartTime();
$filterValue
=
$filter
->getFilterValue();
if
(
$startTime
===
null
||
empty
(
$filterValue
)) {
return
null
;
}
return
$startTime
->gte(Carbon::parse(
$filterValue
)) ?
null
:
false
;
}
Would you like to explore how to write unit tests for these specific
check*
methods to ensure they handle edge cases correctly?
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
Edit
You said create prompt for PR reviewing
You said
create prompt for PR reviewing
Listen
Show more options
Show thinking...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9264
|
|
9263
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)
1. Fix the N+1 Query Risk (Eager Loading)
Your
checkCategoryId
and
checkStageId
methods call
$activity->getCategory()
and
$activity->getStage()
. If these relationships aren't eager-loaded, Laravel will execute a new database query for every check inside the loop.
Suggestion:
Add
category
and
stage
to the initial eager-load array.
PHP
Download code
Copy code
// Before (Line 38)
$activity
= Activity::with([
'user.group'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
// After
$activity
= Activity::with([
'user.group'
,
'category'
,
'stage'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
2. Drastically Reduce Memory Usage
2. Drastically Reduce Memory Usage
Currently, you are hydrating full Eloquent
User
models into memory just to extract their IDs. If a team has hundreds or thousands of users, this will spike memory usage and potentially crash the worker.
Suggestion:
Use the relationship query builder to perform the
pluck
directly at the database level, bypassing Eloquent model hydration entirely.
PHP...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9263
|
|
9262
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)
1. Fix the N+1 Query Risk (Eager Loading)
Your
checkCategoryId
and
checkStageId
methods call
$activity->getCategory()
and
$activity->getStage()
. If these relationships aren't eager-loaded, Laravel will execute a new database query for every check inside the loop.
Suggestion:
Add
category
and
stage
to the initial eager-load array.
PHP
Download code
Copy code
// Before (Line 38)
$activity
= Activity::with([
'user.group'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
// After
$activity
= Activity::with([
'user.group'
,
'category'
,
'stage'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
2. Drastically Reduce Memory Usage
2. Drastically Reduce Memory Usage
Currently, you are hydrating full Eloquent
User
models into memory just to extract their IDs. If a team has hundreds or thousands of users, this will spike memory usage and potentially crash the worker.
Suggestion:
Use the relationship query builder to perform the
pluck
directly at the database level, bypassing Eloquent model hydration entirely.
PHP
Download code
Copy code
// Before (Lines 39-41)
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
// After (No need to eager load 'users' on the Team model anymore)
$team
= Team::findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users()->pluck(
'users.id'
)->toArray();
// Note the `()` after users
3. Harden Type Comparisons
3. Harden Type Comparisons
Since Elasticsearch and databases often return IDs or filter values as strings, strict comparison (
===
) against an Eloquent model's attribute (which might be cast to an integer) can cause false negatives.
Suggestion:
Cast your values before comparison, or use loose equality (
==
) if you are absolutely certain of the payload structure.
PHP
Download code
Copy code
// Example for checkChannelId (Lines 132-135)
private
function
checkChannelId
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
// Cast both to string to ensure safe comparison
return
(
string
)
$filter
->getFilterValue() === (
string
)
$activity
->getType() ?
null
:
false
;
}
// Example for checkProviderId
private
function
checkProviderId
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
return
(
string
)
$filter
->getFilterValue() === (
string
)
$activity
->getProvider() ?
null
:
false
;
}
4. Optimize Date Parsing (Micro-Optimization)
4. Optimize Date Parsing (Micro-Optimization)
If the same date filter applies to multiple searches, you are instantiating Carbon objects repeatedly inside the loop.
Suggestion:
While Carbon is relatively fast, you can extract the date parsing out of the specific check and cache it in the job's state if you anticipate high volumes of searches. Alternatively, just ensure the filter value is valid before parsing:
PHP
Download code
Copy code
private
function
checkStartDate
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
$startTime
=
$activity
->getActualStartTime();
$filterValue
=
$filter
->getFilterValue();
if
(
$startTime
===
null
||
empty
(
$filterValue
)) {
return
null
;
}
return
$startTime
->gte(Carbon::parse(
$filterValue
)) ?
null
:
false
;
}
Would you like to explore how to write unit tests for these specific
check*
methods to ensure they handle edge cases correctly?
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
Edit
You said create prompt for PR reviewing
You said
create prompt for PR reviewing
Listen
Show more options
Show thinking
Gemini said...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9262
|
|
9261
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)
1. Fix the N+1 Query Risk (Eager Loading)
Your
checkCategoryId
and
checkStageId
methods call
$activity->getCategory()
and
$activity->getStage()
. If these relationships aren't eager-loaded, Laravel will execute a new database query for every check inside the loop.
Suggestion:
Add
category
and
stage
to the initial eager-load array.
PHP
Download code
Copy code
// Before (Line 38)
$activity
= Activity::with([
'user.group'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
// After
$activity
= Activity::with([
'user.group'
,
'category'
,
'stage'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
2. Drastically Reduce Memory Usage
2. Drastically Reduce Memory Usage
Currently, you are hydrating full Eloquent
User
models into memory just to extract their IDs. If a team has hundreds or thousands of users, this will spike memory usage and potentially crash the worker.
Suggestion:
Use the relationship query builder to perform the
pluck
directly at the database level, bypassing Eloquent model hydration entirely.
PHP
Download code
Copy code
// Before (Lines 39-41)
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
// After (No need to eager load 'users' on the Team model anymore)
$team
= Team::findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users()->pluck(
'users.id'
)->toArray();
// Note the `()` after users
3. Harden Type Comparisons
3. Harden Type Comparisons
Since Elasticsearch and databases often return IDs or filter values as strings, strict comparison (
===
) against an Eloquent model's attribute (which might be cast to an integer) can cause false negatives.
Suggestion:
Cast your values before comparison, or use loose equality (
==
) if you are absolutely certain of the payload structure.
PHP
Download code
Copy code
// Example for checkChannelId (Lines 132-135)
private
function
checkChannelId
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
// Cast both to string to ensure safe comparison
return
(
string
)
$filter
->getFilterValue() === (
string
)
$activity
->getType() ?
null
:
false
;
}
// Example for checkProviderId
private...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9261
|
|
9260
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9260
|
|
9259
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives....
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9259
|
|
9258
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9258
|
|
9257
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)
1. Fix the N+1 Query Risk (Eager Loading)
Your
checkCategoryId
and
checkStageId
methods call
$activity->getCategory()
and
$activity->getStage()
. If these relationships aren't eager-loaded, Laravel will execute a new database query for every check inside the loop.
Suggestion:
Add
category
and
stage
to the initial eager-load array.
PHP
Download code...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9257
|
|
9256
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said
what would be your suggestion?
Listen...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9256
|
|
9255
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9255
|
|
9254
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
Edit
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)
1. Fix the N+1 Query Risk (Eager Loading)
Your
checkCategoryId
and
checkStageId
methods call
$activity->getCategory()
and
$activity->getStage()
. If these relationships aren't eager-loaded, Laravel will execute a new database query for every check inside the loop.
Suggestion:
Add
category
and
stage
to the initial eager-load array.
PHP
Download code
Copy code
// Before (Line 38)
$activity
= Activity::with([
'user.group'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
// After
$activity
= Activity::with([
'user.group'
,
'category'
,
'stage'
])->uuid(
$this
->activityUuid,
false
)->firstOrFail();
2. Drastically Reduce Memory Usage
2. Drastically Reduce Memory Usage
Currently, you are hydrating full Eloquent
User
models into memory just to extract their IDs. If a team has hundreds or thousands of users, this will spike memory usage and potentially crash the worker.
Suggestion:
Use the relationship query builder to perform the
pluck
directly at the database level, bypassing Eloquent model hydration entirely.
PHP
Download code
Copy code
// Before (Lines 39-41)
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
// After (No need to eager load 'users' on the Team model anymore)
$team
= Team::findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users()->pluck(
'users.id'
)->toArray();
// Note the `()` after users
3. Harden Type Comparisons
3. Harden Type Comparisons
Since Elasticsearch and databases often return IDs or filter values as strings, strict comparison (
===
) against an Eloquent model's attribute (which might be cast to an integer) can cause false negatives.
Suggestion:
Cast your values before comparison, or use loose equality (
==
) if you are absolutely certain of the payload structure.
PHP
Download code
Copy code
// Example for checkChannelId (Lines 132-135)
private
function
checkChannelId
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
// Cast both to string to ensure safe comparison
return
(
string
)
$filter
->getFilterValue() === (
string
)
$activity
->getType() ?
null
:
false
;
}
// Example for checkProviderId
private
function
checkProviderId
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{
return
(
string
)
$filter
->getFilterValue() === (
string
)
$activity
->getProvider() ?
null
:
false
;
}
4. Optimize Date Parsing (Micro-Optimization)
4. Optimize Date Parsing (Micro-Optimization)
If the same date filter applies to multiple searches, you are instantiating Carbon objects repeatedly inside the loop.
Suggestion:
While Carbon is relatively fast, you can extract the date parsing out of the specific check and cache it in the job's state if you anticipate high volumes of searches. Alternatively, just ensure the filter value is valid before parsing:
PHP
Download code
Copy code
private
function
checkStartDate
(
Activity
$activity
, SearchFilter
$filter
): ?
bool
{...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9254
|
|
9253
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The
check*
methods are designed to return
false
only if there is an absolute mismatch
(e.g., the filter requires a minimum duration of 10, but the activity is 5).
The Pass:
If the filter matches, or if the activity lacks the data needed to make a firm decision (e.g., no stage is assigned, or the filter type is unrecognized), the method returns
null
. This acts as a pass, delegating the final strict evaluation to Elasticsearch later in the pipeline.
If any filter returns
false
, the search is skipped, saving a queue dispatch.
3. Evaluation
3. Evaluation
Overall, this is a
highly effective performance optimization
.
Pros:
It significantly reduces queue bloat. By filtering out obvious mismatches in-memory, you save the overhead of serializing, dispatching, picking up, and processing jobs that are destined to fail or do nothing. The use of
match
makes the routing exceptionally clean, and the nullable return design is a smart way to avoid false negatives.
Cons/Trade-offs:
You are trading background queue worker time for a slight increase in synchronous memory and CPU usage on the current job. However, queue I/O is almost always more expensive than simple in-memory comparisons, making this a worthwhile trade.
4. Potential Issues & Bottlenecks
4. Potential Issues & Bottlenecks
While the logic is sound, there are a few architectural bottlenecks and risks introduced or maintained in this diff:
Potential N+1 Query / Lazy Loading Issues:
In the
handle
method, the activity is loaded with
Activity::with(['user.group'])
. However, inside your new
check*
methods, you are calling:
$activity->getCategory()?->getUuid()
$activity->getStage()?->getUuid()
If
category
and
stage
are Eloquent relationships and are not eager-loaded on the original
$activity
model, calling these methods inside a loop of
$totalSearches
will trigger lazy-loaded database queries.
Fix:
Update line 38 to:
$activity = Activity::with(['user.group', 'category', 'stage'])->...
Massive Memory Consumption (Pre-existing but critical):
Lines 39-41:
PHP
Download code
Copy code
$team
= Team::with([
'users'
])->findOrFail(
$activity
->user->team_id);
$usersIds
=
$team
->users->pluck(
'id'
)->toArray();
If a team has thousands of users,
Team::with(['users'])
hydrates thousands of full Eloquent User models into memory just to extract their IDs.
Fix:
Bypass hydrating the models entirely by querying the relationship directly:
$usersIds = $team->users()->pluck('id')->toArray();
Repeated Carbon Parsing in Loops:
In
checkStartDate
and
checkEndDate
, you parse the filter date:
Carbon::parse($filter->getFilterValue())
. If the same date filter applies to multiple searches, Carbon is being instantiated repeatedly in the loop. While Carbon is relatively fast, doing this hundreds of times adds unnecessary CPU cycles.
Fix:
This is a minor nitpick, but if performance is the absolute goal, you could memoize/cache the parsed filter values within the job.
Loose Type Comparisons:
In methods like
checkChannelId
or
checkLanguages
, you are using strict equality (
===
). Ensure that
$filter->getFilterValue()
(which is typically returned as a string from a database payload) matches the exact type returned by
$activity->getType()
or
$activityLangCode
. If one is an integer
1
and the other is a string
"1"
, the strict check will return
false
, incorrectly preventing the nudge from sending.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
Edit
You said what would be your suggestion?
You said
what would be your suggestion?
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here are actionable suggestions to address the potential issues and bottlenecks identified in the review. These optimizations focus on reducing memory footprint, preventing lazy-loading, and ensuring data type resilience.
1. Fix the N+1 Query Risk (Eager Loading)
1. Fix the N+1 Query Risk (Eager Loading)
Your
checkCategoryId
and
checkStageId
methods call
$activity->getCategory()
and
$activity->getStage()
. If these relationships aren't eager-loaded, Laravel will execute a new database query for every check inside the loop....
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9253
|
|
9252
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9252
|
|
9251
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20493 smart instant nudge pre filtering by niko</tabTitle>” with “<selection>@@ -4,16 +4,17 @@445namespace Jiminny\Component\Nudge\Job;5namespace Jiminny\Component\Nudge\Job;667+use Carbon\Carbon;7use Illuminate\Bus\Queueable;8use Illuminate\Bus\Queueable;8use Illuminate\Contracts\Queue\ShouldQueue;9use Illuminate\Contracts\Queue\ShouldQueue;9-use Illuminate\Database\Eloquent\Builder;10use Illuminate\Foundation\Bus\Dispatchable;10use Illuminate\Foundation\Bus\Dispatchable;11use Illuminate\Queue\InteractsWithQueue;11use Illuminate\Queue\InteractsWithQueue;12use Illuminate\Queue\SerializesModels;12use Illuminate\Queue\SerializesModels;13use Illuminate\Support\Facades\Log;13use Illuminate\Support\Facades\Log;14use Jiminny\Component\Queue\Constants;14use Jiminny\Component\Queue\Constants;15use Jiminny\Models\Activity;15use Jiminny\Models\Activity;16use Jiminny\Models\Activity\Search;16use Jiminny\Models\Activity\Search;17+use Jiminny\Models\Activity\SearchFilter;17use Jiminny\Models\Nudge;18use Jiminny\Models\Nudge;18use Jiminny\Models\Team;19use Jiminny\Models\Team;1920@@ -34,37 +35,201 @@343535public function handle(): void36public function handle(): void36 {37 {37-/** @var Activity $activity */38+$activity = Activity::with(['user.group'])->uuid($this->activityUuid, false)->firstOrFail();38-$activity = Activity::with(['user'])->uuid($this->activityUuid, false)->firstOrFail();39-40-/** @var Team $team */41$team = Team::with(['users'])->findOrFail($activity->user->team_id);39$team = Team::with(['users'])->findOrFail($activity->user->team_id);424043- Log::info(__METHOD__ . " Running for team $team->id.", [44-'activity_id' => $activity->id,45-'activity_uuid' => $this->activityUuid,46-'team_id' => $team->id,47- ]);48-49$usersIds = $team->users->pluck('id')->toArray();41$usersIds = $team->users->pluck('id')->toArray();504251-$searchesWithImmediateNudges = Activity\Search::with(['nudges'])43+$searchesWithImmediateNudges = Activity\Search::with([44+'nudges' => fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY),45+'filters',46+ ])52 ->whereIn('user_id', $usersIds)47 ->whereIn('user_id', $usersIds)53- ->whereHas('nudges', function (Builder $query) {48+ ->whereHas('nudges', fn ($q) => $q->where('frequency', Nudge::FREQ_INSTANTLY))54-$query->where('frequency', Nudge::FREQ_INSTANTLY);49+ ->get();55- })->get();50+51+$totalSearches = $searchesWithImmediateNudges->count();52+$skippedCount = 0;53+54+$searchesWithImmediateNudges->each(function (Search $search) use ($activity, &$skippedCount): void {55+$matchResult = $this->activityMightMatchSearch($activity, $search);565657-$searchesWithImmediateNudges->each(function (Search $search): void {57+if (! $matchResult['matches']) {58-$search->nudges->each(function (Nudge $nudge): void {58+$skippedCount++;59-// Filter only nudges that are set to be sent instantly59+ Log::info('Pre-filter skipped nudge dispatch', [60-if ($nudge->frequency === Nudge::FREQ_INSTANTLY) {60+'activity_uuid' => $activity->getUuid(),61- Log::info("Dispatching ProcessNudgeSearchJob for nudge $nudge->id.");61+'search_id' => $search->getId(),62+'mismatched_filter' => $matchResult['mismatch_reason'],63+'filter_value' => $matchResult['filter_value'],64+ ]);626563- ProcessNudgeSearchJob::dispatch($nudge);66+return;67+ }646865- Log::info("ProcessNudgeSearchJob dispatched for nudge $nudge->id.");69+$search->nudges->each(fn ($nudge) => ProcessNudgeSearchJob::dispatch($nudge));66- }67- });68 });70 });71+72+if ($totalSearches > 0) {73+ Log::info('Nudge pre-filter results', [74+'activity_uuid' => $activity->getUuid(),75+'total_searches' => $totalSearches,76+'skipped' => $skippedCount,77+'dispatched' => $totalSearches - $skippedCount,78+'skip_rate' => round(($skippedCount / $totalSearches) * 100, 2) . '%',79+ ]);80+ }81+ }82+83+private function activityMightMatchSearch(Activity $activity, Search $search): array84+ {85+foreach ($search->filters as $filter) {86+$filterName = $filter->getFilterProperty();87+88+$result = match ($filterName) {89+'group_id' => $this->checkGroupId($activity, $filter),90+'user_id' => $this->checkUserId($activity, $filter),91+'channel_id' => $this->checkChannelId($activity, $filter),92+'provider_id' => $this->checkProviderId($activity, $filter),93+'category_id' => $this->checkCategoryId($activity, $filter),94+'min_duration' => $this->checkMinDuration($activity, $filter),95+'max_duration' => $this->checkMaxDuration($activity, $filter),96+'not_logged' => $this->checkNotLogged($activity, $filter),97+'only_recorded' => $this->checkOnlyRecorded($activity, $filter),98+'languages' => $this->checkLanguages($activity, $filter),99+'stage_id' => $this->checkStageId($activity, $filter),100+'start_date' => $this->checkStartDate($activity, $filter),101+'end_date' => $this->checkEndDate($activity, $filter),102+default => null, // Unknown filter - can't pre-check, assume might match103+ };104+105+if ($result === false) {106+return [107+'matches' => false,108+'mismatch_reason' => $filterName,109+'filter_value' => $filter->getFilterValue(),110+ ];111+ }112+ }113+114+return ['matches' => true];115+ }116+117+private function checkGroupId(Activity $activity, SearchFilter $filter): ?bool118+ {119+$groupUuid = $activity->user->getGroup()?->getUuid();120+if ($groupUuid === null) {121+return null;122+ }123+124+return $filter->getFilterValue() === $groupUuid ? null : false;125+ }126+127+private function checkUserId(Activity $activity, SearchFilter $filter): ?bool128+ {129+return $filter->getFilterValue() === $activity->user->getUuid() ? null : false;130+ }131+132+private function checkChannelId(Activity $activity, SearchFilter $filter): ?bool133+ {134+return $filter->getFilterValue() === $activity->getType() ? null : false;135+ }136+137+private function checkProviderId(Activity $activity, SearchFilter $filter): ?bool138+ {139+return $filter->getFilterValue() === $activity->getProvider() ? null : false;140+ }141+142+private function checkCategoryId(Activity $activity, SearchFilter $filter): ?bool143+ {144+$categoryId = $activity->getCategory()?->getUuid();145+if ($categoryId === null) {146+return null;147+ }148+149+return $filter->getFilterValue() === $categoryId ? null : false;150+ }151+152+private function checkMinDuration(Activity $activity, SearchFilter $filter): ?bool153+ {154+return $activity->getDuration() >= (float) $filter->getFilterValue() ? null : false;155+ }156+157+private function checkMaxDuration(Activity $activity, SearchFilter $filter): ?bool158+ {159+return $activity->getDuration() <= (float) $filter->getFilterValue() ? null : false;160+ }161+162+private function checkNotLogged(Activity $activity, SearchFilter $filter): ?bool163+ {164+if (! $filter->getFilterValue()) {165+return null;166+ }167+168+$isLogged = $activity->getCrmProviderId() !== null;169+$isInternal = $activity->isInternal();170+171+return (! $isLogged || $isInternal) ? null : false;172+ }173+174+private function checkOnlyRecorded(Activity $activity, SearchFilter $filter): ?bool175+ {176+$filterValue = (int) $filter->getFilterValue();177+$hasRecording = $activity->recording_state !== Activity::RECORDING_OFF;178+179+return match ($filterValue) {180+1 => $hasRecording ? null : false, // Only recorded181+2 => ! $hasRecording ? null : false, // Only non-recorded182+default => null,183+ };184+ }185+186+private function checkStageId(Activity $activity, SearchFilter $filter): ?bool187+ {188+$stageId = $activity->getStage()?->getUuid();189+if ($stageId === null) {190+return null; // No stage, let ES handle191+ }192+193+return $filter->getFilterValue() === $stageId ? null : false;194+ }195+196+private function checkStartDate(Activity $activity, SearchFilter $filter): ?bool197+ {198+$startTime = $activity->getActualStartTime();199+if ($startTime === null) {200+return null;201+ }202+203+$filterDate = Carbon::parse($filter->getFilterValue());204+205+return $startTime->gte($filterDate) ? null : false;206+ }207+208+private function checkEndDate(Activity $activity, SearchFilter $filter): ?bool209+ {210+$endTime = $activity->getActualEndTime();211+if ($endTime === null) {212+return null;213+ }214+215+$filterDate = Carbon::parse($filter->getFilterValue())->endOfDay();216+217+return $endTime->lte($filterDate) ? null : false;218+ }219+220+private function checkLanguages(Activity $activity, SearchFilter $filter): ?bool221+ {222+$activityLanguage = $activity->getLanguageCode();223+if ($activityLanguage === null) {224+return null;225+ }226+227+// Filter values are always base language codes (e.g., 'en', 'fr').228+// Extract base code from activity locale (e.g., en_GB -> en) to match ES query behavior.229+// See LanguageFilterDefinition::getQueries() which uses regex: $language . '_[a-z]{2}'230+$activityLangCode = explode('_', $activityLanguage)[0];231+$filterLanguage = $filter->getFilterValue();232+233+return $activityLangCode === $filterLanguage ? null : false;69 }234 }70}235}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This code selection introduces a
pre-filtering mechanism
to the
Jiminny\Component\Nudge\Job
namespace. The primary goal is to optimize performance by validating an activity against search criteria in-memory before dispatching costly background jobs.
Core Functionality Changes
Core Functionality Changes
Selective Loading:
The
handle()
method now eager-loads
user.group
for activities and specifically filters for
FREQ_INSTANTLY
nudges and their associated search filters.
Pre-Dispatch Validation:
Instead of dispatching jobs for every instant nudge, the system now calls
activityMightMatchSearch()
.
Short-Circuiting:
If an activity fails a pre-filter check (e.g., mismatched user ID or duration), the job dispatch is skipped, and the event is logged with the specific mismatch reason.
Performance Metrics:
Adds summary logging at the end of the process to track:
Total searches processed.
Number of skipped dispatches.
The "Skip Rate" percentage.
Implemented Filter Checks
Implemented Filter Checks
The logic includes a
match
expression to validate various activity attributes against
SearchFilter
values:
Identity & Metadata:
Matches for
group_id
,
user_id
,
channel_id
, and
provider_id
.
Temporal & Duration:
* Validates
min_duration
and
max_duration
.
Checks
start_date
and
end_date
using Carbon parsing.
Activity State:
*
Logging:
Determines if an activity is logged to a CRM or is internal via
checkNotLogged
.
Recording:
Uses
checkOnlyRecorded
to filter based on whether a recording exists.
Categorization:
Checks for
category_id
,
stage_id
, and
languages
.
Note:
The language check specifically extracts base codes (e.g.,
en
from
en_GB
) to align with Elasticsearch behavior.
Logic Design
Logic Design
Nullable Returns:
Filter methods return
null
if a match is possible (or cannot be determined), and
false
only when a definitive mismatch is found.
Safe Fallback:
If a filter type is unknown (
default
), the system assumes a match and proceeds to dispatch, ensuring no false negatives.
Good response
Bad response
Share & export
Copy
Show more options
Copy prompt
You said what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
You said
what is the logic behind diff. Act as code reviewer. First review the changes then explain it and then evaluate. Try to find potential issues and bottlenecks
Expand
Listen
Show more options
Show thinking
Gemini said
Gemini said
Here is a review of the changes provided in the diff, broken down by your requested structure.
1. Review of Changes
1. Review of Changes
This PR refactors the
ProcessNudgeSearchJob
dispatching mechanism within the
handle()
method.
Query Optimization:
Eager loads necessary relationships (
user.group
,
filters
) and scopes the
nudges
relationship at the database level to only fetch
FREQ_INSTANTLY
records.
In-Memory Filtering:
Introduces a pre-filtering mechanism (
activityMightMatchSearch
) that checks the
Activity
against
SearchFilter
criteria before deciding to dispatch a job.
Helper Methods:
Adds 13 private
check*
methods to handle the specific comparison logic for different filter types (e.g., duration, dates, categories, stages).
Observability:
Adds structured logging to record exactly why a nudge was skipped and outputs a summary report (total, skipped, dispatched, skip rate) at the end of the execution.
2. Explanation of Logic
2. Explanation of Logic
The core philosophy of this change is
"veto-based short-circuiting"
.
Previously, the job fetched all searches with instant nudges for a team's users and blindly dispatched a queue job for every single one. The heavy lifting of deciding if the activity actually matched the search was likely deferred to the queue worker and Elasticsearch.
Now, the code evaluates the filters in the current process:
It iterates through each
Search
and its associated
SearchFilter
s.
It routes the filter to a specific
check*
method using a
match
expression based on the filter's property name.
The Veto:
The...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9251
|
|
9250
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
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]
New Chat
Open menu for conversation actions.
Close menu
Search for chats
Search for chats
New chat
New chat
My stuff
My stuff
Gems
Gems
Chats
Chats
Testing LoadDocumentsAction Hydration Logic
Testing LoadDocumentsAction Hydration Logic
More options for Testing LoadDocumentsAction Hydration Logic
Optimizing Nudge Job Dispatch
Optimizing Nudge Job Dispatch
More options for Optimizing Nudge Job Dispatch
Git Status: Unstaged Changes and Updates
Git Status: Unstaged Changes and Updates
More options for Git Status: Unstaged Changes and Updates
Laravel Events, Listeners, and Queues
Laravel Events, Listeners, and Queues
More options for Laravel Events, Listeners, and Queues
`subMonthNoOverflow` Prevents Date Overflow
`subMonthNoOverflow` Prevents Date Overflow
More options for `subMonthNoOverflow` Prevents Date Overflow
Jiminny MCP Connector PRD Review
Jiminny MCP Connector PRD Review
More options for Jiminny MCP Connector PRD Review
MCP OAuth for Claude Code & Chat
MCP OAuth for Claude Code & Chat
More options for MCP OAuth for Claude Code & Chat
Slack DND Status: How to Change
Slack DND Status: How to Change
More options for Slack DND Status: How to Change
Laravel Query Refactoring and Security
Laravel Query Refactoring and Security
More options for Laravel Query Refactoring and Security
Git Checkout Remote Branch
Git Checkout Remote Branch
More options for Git Checkout Remote Branch
URL Decode Salesforce SOQL Query
URL Decode Salesforce SOQL Query
More options for URL Decode Salesforce SOQL Query
Firefox Console Log Persistence
Firefox Console Log Persistence
More options for Firefox Console Log Persistence
Exaplain <?php declare(strict_types=1); namespace Jiminny\Services\Crm\CrmObjects\Validators; use Exception; use Carbon\CarbonImmutable; use Illuminate\Contracts\Events\Dispatcher; use Jiminny\Contracts\Crm\SyncableCrmObjectInterface; use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface; use Jiminny\Events\Crm\RemoteCrmRecordDeleted; use Jiminny\Exceptions\HttpNotFoundException; use Jiminny\Models\Account; use Jiminny\Models\Contact; use Jiminny\Models\Lead; use Jiminny\Models\Opportunity; use Psr\Log\LoggerInterface; /** * Validate if a CRM record is stale. * * If a record hasn't been updated recently, we should test against the real CRM * to validate if this record still exists, or was deleted / purged. */ class StaleRecordValidator { /** * If a CRM entity hasn't been updated in more than 120 days, the object may be potentially stale */ private const int STALE_THRESHOLD_DAYS = 120; public function __construct( private readonly LoggerInterface $logger, private readonly Dispatcher $dispatcher ) { } public function filterStale( ?SyncableCrmObjectInterface $crmObjectCandidate, ?SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { if (! $crmObjectCandidate) { return null; } if (! $crmService) { return $crmObjectCandidate; } $thresholdDate = CarbonImmutable::now()->subDays(self::STALE_THRESHOLD_DAYS); if ($thresholdDate->isBefore($crmObjectCandidate->getAttribute('updated_at'))) { return $crmObjectCandidate; } return $this->syncPotentiallyStaleObject($crmObjectCandidate, $crmService); } private function syncPotentiallyStaleObject( SyncableCrmObjectInterface $crmObject, SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { $crmProviderId = $crmObject->getCrmProviderId(); if (empty($crmProviderId)) { $this->logger->warning('[StaleRecordValidator] CRM object has empty crm_provider_id', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), ]); return $crmObject; } try { $this->logger->info('[StaleRecordValidator] Syncing potentially stale record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'updated_at' => $crmObject->getAttribute('updated_at'), ]); $syncedObject = match (true) { $crmObject instanceof Lead => $crmService->syncLead($crmProviderId), $crmObject instanceof Account => $crmService->syncAccount($crmProviderId), $crmObject instanceof Contact => $crmService->syncContact($crmProviderId), $crmObject instanceof Opportunity => $crmService->syncOpportunity($crmProviderId), }; if ($syncedObject === null) { return $this->purgeStaleRecord($crmObject); } $syncedObject->touch(); $this->logger->info('[StaleRecordValidator] Record synced successfully', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, ]); return $syncedObject; } catch (HttpNotFoundException) { return $this->purgeStaleRecord($crmObject); } catch (Exception $e) { $this->logger->error('[StaleRecordValidator] Failed to sync record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'error' => $e->getMessage(), ]); return $crmObject; } } private function purgeStaleRecord(SyncableCrmObjectInterface $crmObject): null { $this->logger->info('[StaleRecordValidator] Record not found in remote CRM', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmObject->getCrmProviderId(), ]); $this->dispatcher->dispatch(new RemoteCrmRecordDeleted($crmObject)); return null; } }
Exaplain <?php declare(strict_types=1); namespace Jiminny\Services\Crm\CrmObjects\Validators; use Exception; use Carbon\CarbonImmutable; use Illuminate\Contracts\Events\Dispatcher; use Jiminny\Contracts\Crm\SyncableCrmObjectInterface; use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface; use Jiminny\Events\Crm\RemoteCrmRecordDeleted; use Jiminny\Exceptions\HttpNotFoundException; use Jiminny\Models\Account; use Jiminny\Models\Contact; use Jiminny\Models\Lead; use Jiminny\Models\Opportunity; use Psr\Log\LoggerInterface; /** * Validate if a CRM record is stale. * * If a record hasn't been updated recently, we should test against the real CRM * to validate if this record still exists, or was deleted / purged. */ class StaleRecordValidator { /** * If a CRM entity hasn't been updated in more than 120 days, the object may be potentially stale */ private const int STALE_THRESHOLD_DAYS = 120; public function __construct( private readonly LoggerInterface $logger, private readonly Dispatcher $dispatcher ) { } public function filterStale( ?SyncableCrmObjectInterface $crmObjectCandidate, ?SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { if (! $crmObjectCandidate) { return null; } if (! $crmService) { return $crmObjectCandidate; } $thresholdDate = CarbonImmutable::now()->subDays(self::STALE_THRESHOLD_DAYS); if ($thresholdDate->isBefore($crmObjectCandidate->getAttribute('updated_at'))) { return $crmObjectCandidate; } return $this->syncPotentiallyStaleObject($crmObjectCandidate, $crmService); } private function syncPotentiallyStaleObject( SyncableCrmObjectInterface $crmObject, SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { $crmProviderId = $crmObject->getCrmProviderId(); if (empty($crmProviderId)) { $this->logger->warning('[StaleRecordValidator] CRM object has empty crm_provider_id', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), ]); return $crmObject; } try { $this->logger->info('[StaleRecordValidator] Syncing potentially stale record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'updated_at' => $crmObject->getAttribute('updated_at'), ]); $syncedObject = match (true) { $crmObject instanceof Lead => $crmService->syncLead($crmProviderId), $crmObject instanceof Account => $crmService->syncAccount($crmProviderId), $crmObject instanceof Contact => $crmService->syncContact($crmProviderId), $crmObject instanceof Opportunity => $crmService->syncOpportunity($crmProviderId), }; if ($syncedObject === null) { return $this->purgeStaleRecord($crmObject); } $syncedObject->touch(); $this->logger->info('[StaleRecordValidator] Record synced successfully', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, ]); return $syncedObject; } catch (HttpNotFoundException) { return $this->purgeStaleRecord($crmObject); } catch (Exception $e) { $this->logger->error('[StaleRecordValidator] Failed to sync record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'error' => $e->getMessage(), ]); return $crmObject; } } private function purgeStaleRecord(SyncableCrmObjectInterface $crmObject): null { $this->logger->info('[StaleRecordValidator] Record not found in remote CRM', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmObject->getCrmProviderId(), ]); $this->dispatcher->dispatch(new RemoteCrmRecordDeleted($crmObject)); return null; } }
More options for Exaplain <?php declare(strict_types=1); namespace Jiminny\Services\Crm\CrmObjects\Validators; use Exception; use Carbon\CarbonImmutable; use Illuminate\Contracts\Events\Dispatcher; use Jiminny\Contracts\Crm\SyncableCrmObjectInterface; use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface; use Jiminny\Events\Crm\RemoteCrmRecordDeleted; use Jiminny\Exceptions\HttpNotFoundException; use Jiminny\Models\Account; use Jiminny\Models\Contact; use Jiminny\Models\Lead; use Jiminny\Models\Opportunity; use Psr\Log\LoggerInterface; /** * Validate if a CRM record is stale. * * If a record hasn't been updated recently, we should test against the real CRM * to validate if this record still exists, or was deleted / purged. */ class StaleRecordValidator { /** * If a CRM entity hasn't been updated in more than 120 days, the object may be potentially stale */ private const int STALE_THRESHOLD_DAYS = 120; public function __construct( private readonly LoggerInterface $logger, private readonly Dispatcher $dispatcher ) { } public function filterStale( ?SyncableCrmObjectInterface $crmObjectCandidate, ?SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { if (! $crmObjectCandidate) { return null; } if (! $crmService) { return $crmObjectCandidate; } $thresholdDate = CarbonImmutable::now()->subDays(self::STALE_THRESHOLD_DAYS); if ($thresholdDate->isBefore($crmObjectCandidate->getAttribute('updated_at'))) { return $crmObjectCandidate; } return $this->syncPotentiallyStaleObject($crmObjectCandidate, $crmService); } private function syncPotentiallyStaleObject( SyncableCrmObjectInterface $crmObject, SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { $crmProviderId = $crmObject->getCrmProviderId(); if (empty($crmProviderId)) { $this->logger->warning('[StaleRecordValidator] CRM object has empty crm_provider_id', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), ]); return $crmObject; } try { $this->logger->info('[StaleRecordValidator] Syncing potentially stale record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'updated_at' => $crmObject->getAttribute('updated_at'), ]); $syncedObject = match (true) { $crmObject instanceof Lead => $crmService->syncLead($crmProviderId), $crmObject instanceof Account => $crmService->syncAccount($crmProviderId), $crmObject instanceof Contact => $crmService->syncContact($crmProviderId), $crmObject instanceof Opportunity => $crmService->syncOpportunity($crmProviderId), }; if ($syncedObject === null) { return $this->purgeStaleRecord($crmObject); } $syncedObject->touch(); $this->logger->info('[StaleRecordValidator] Record synced successfully', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, ]); return $syncedObject; } catch (HttpNotFoundException) { return $this->purgeStaleRecord($crmObject); } catch (Exception $e) { $this->logger->error('[StaleRecordValidator] Failed to sync record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'error' => $e->getMessage(), ]); return $crmObject; } } private function purgeStaleRecord(SyncableCrmObjectInterface $crmObject): null { $this->logger->info('[StaleRecordValidator] Record not found in remote CRM', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmObject->getCrmProviderId(), ]); $this->dispatcher->dispatch(new RemoteCrmRecordDeleted($crmObject)); return null; } }
Google Meet Speaker Data on Firefox
Google Meet Speaker Data on Firefox
More options for Google Meet Speaker Data on Firefox
Activity
Activity
Settings & help
Settings & help
Upgrade
Upgrade
Sofia, Bulgaria
From your IP address
•
Update location
Summarize page
Summarize page
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (33)
Pull requests
(
33
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (2)
Security and quality
(
2
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
Jy 20820 es reindex stream model hydration #12059 Edit title
Jy 20820 es reindex stream model hydration
#
12059
Edit title
Preview
Preview
Awaiting approval
Awaiting approval
Code
Code
Open
Vasil-Jiminny
Vasil-Jiminny
wants to merge 35 commits into
master
master
from
JY-20820-es-reindex-stream-model-hydration
JY-20820-es-reindex-stream-model-hydration
Copy head branch name to clipboard
Lines changed: 363 additions & 140 deletions
Conversation (8)
Conversation
(
8
)
Commits (35)
Commits
(
35
)
Checks (3)
Checks
(
3
)
Files changed (12)
Files changed
(
12
)...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9250
|
|
9249
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
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]
New Chat
Open menu for conversation actions.
Close menu
Search for chats
Search for chats
New chat
New chat
My stuff
My stuff
Gems
Gems
Chats
Chats
Testing LoadDocumentsAction Hydration Logic
Testing LoadDocumentsAction Hydration Logic
More options for Testing LoadDocumentsAction Hydration Logic
Optimizing Nudge Job Dispatch
Optimizing Nudge Job Dispatch
More options for Optimizing Nudge Job Dispatch
Git Status: Unstaged Changes and Updates
Git Status: Unstaged Changes and Updates
More options for Git Status: Unstaged Changes and Updates
Laravel Events, Listeners, and Queues
Laravel Events, Listeners, and Queues
More options for Laravel Events, Listeners, and Queues
`subMonthNoOverflow` Prevents Date Overflow
`subMonthNoOverflow` Prevents Date Overflow
More options for `subMonthNoOverflow` Prevents Date Overflow
Jiminny MCP Connector PRD Review
Jiminny MCP Connector PRD Review
More options for Jiminny MCP Connector PRD Review
MCP OAuth for Claude Code & Chat
MCP OAuth for Claude Code & Chat
More options for MCP OAuth for Claude Code & Chat
Slack DND Status: How to Change
Slack DND Status: How to Change
More options for Slack DND Status: How to Change
Laravel Query Refactoring and Security
Laravel Query Refactoring and Security
More options for Laravel Query Refactoring and Security
Git Checkout Remote Branch
Git Checkout Remote Branch
More options for Git Checkout Remote Branch
URL Decode Salesforce SOQL Query
URL Decode Salesforce SOQL Query
More options for URL Decode Salesforce SOQL Query
Firefox Console Log Persistence
Firefox Console Log Persistence
More options for Firefox Console Log Persistence
Exaplain <?php declare(strict_types=1); namespace Jiminny\Services\Crm\CrmObjects\Validators; use Exception; use Carbon\CarbonImmutable; use Illuminate\Contracts\Events\Dispatcher; use Jiminny\Contracts\Crm\SyncableCrmObjectInterface; use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface; use Jiminny\Events\Crm\RemoteCrmRecordDeleted; use Jiminny\Exceptions\HttpNotFoundException; use Jiminny\Models\Account; use Jiminny\Models\Contact; use Jiminny\Models\Lead; use Jiminny\Models\Opportunity; use Psr\Log\LoggerInterface; /** * Validate if a CRM record is stale. * * If a record hasn't been updated recently, we should test against the real CRM * to validate if this record still exists, or was deleted / purged. */ class StaleRecordValidator { /** * If a CRM entity hasn't been updated in more than 120 days, the object may be potentially stale */ private const int STALE_THRESHOLD_DAYS = 120; public function __construct( private readonly LoggerInterface $logger, private readonly Dispatcher $dispatcher ) { } public function filterStale( ?SyncableCrmObjectInterface $crmObjectCandidate, ?SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { if (! $crmObjectCandidate) { return null; } if (! $crmService) { return $crmObjectCandidate; } $thresholdDate = CarbonImmutable::now()->subDays(self::STALE_THRESHOLD_DAYS); if ($thresholdDate->isBefore($crmObjectCandidate->getAttribute('updated_at'))) { return $crmObjectCandidate; } return $this->syncPotentiallyStaleObject($crmObjectCandidate, $crmService); } private function syncPotentiallyStaleObject( SyncableCrmObjectInterface $crmObject, SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { $crmProviderId = $crmObject->getCrmProviderId(); if (empty($crmProviderId)) { $this->logger->warning('[StaleRecordValidator] CRM object has empty crm_provider_id', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), ]); return $crmObject; } try { $this->logger->info('[StaleRecordValidator] Syncing potentially stale record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'updated_at' => $crmObject->getAttribute('updated_at'), ]); $syncedObject = match (true) { $crmObject instanceof Lead => $crmService->syncLead($crmProviderId), $crmObject instanceof Account => $crmService->syncAccount($crmProviderId), $crmObject instanceof Contact => $crmService->syncContact($crmProviderId), $crmObject instanceof Opportunity => $crmService->syncOpportunity($crmProviderId), }; if ($syncedObject === null) { return $this->purgeStaleRecord($crmObject); } $syncedObject->touch(); $this->logger->info('[StaleRecordValidator] Record synced successfully', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, ]); return $syncedObject; } catch (HttpNotFoundException) { return $this->purgeStaleRecord($crmObject); } catch (Exception $e) { $this->logger->error('[StaleRecordValidator] Failed to sync record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'error' => $e->getMessage(), ]); return $crmObject; } } private function purgeStaleRecord(SyncableCrmObjectInterface $crmObject): null { $this->logger->info('[StaleRecordValidator] Record not found in remote CRM', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmObject->getCrmProviderId(), ]); $this->dispatcher->dispatch(new RemoteCrmRecordDeleted($crmObject)); return null; } }
Exaplain <?php declare(strict_types=1); namespace Jiminny\Services\Crm\CrmObjects\Validators; use Exception; use Carbon\CarbonImmutable; use Illuminate\Contracts\Events\Dispatcher; use Jiminny\Contracts\Crm\SyncableCrmObjectInterface; use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface; use Jiminny\Events\Crm\RemoteCrmRecordDeleted; use Jiminny\Exceptions\HttpNotFoundException; use Jiminny\Models\Account; use Jiminny\Models\Contact; use Jiminny\Models\Lead; use Jiminny\Models\Opportunity; use Psr\Log\LoggerInterface; /** * Validate if a CRM record is stale. * * If a record hasn't been updated recently, we should test against the real CRM * to validate if this record still exists, or was deleted / purged. */ class StaleRecordValidator { /** * If a CRM entity hasn't been updated in more than 120 days, the object may be potentially stale */ private const int STALE_THRESHOLD_DAYS = 120; public function __construct( private readonly LoggerInterface $logger, private readonly Dispatcher $dispatcher ) { } public function filterStale( ?SyncableCrmObjectInterface $crmObjectCandidate, ?SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { if (! $crmObjectCandidate) { return null; } if (! $crmService) { return $crmObjectCandidate; } $thresholdDate = CarbonImmutable::now()->subDays(self::STALE_THRESHOLD_DAYS); if ($thresholdDate->isBefore($crmObjectCandidate->getAttribute('updated_at'))) { return $crmObjectCandidate; } return $this->syncPotentiallyStaleObject($crmObjectCandidate, $crmService); } private function syncPotentiallyStaleObject( SyncableCrmObjectInterface $crmObject, SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { $crmProviderId = $crmObject->getCrmProviderId(); if (empty($crmProviderId)) { $this->logger->warning('[StaleRecordValidator] CRM object has empty crm_provider_id', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), ]); return $crmObject; } try { $this->logger->info('[StaleRecordValidator] Syncing potentially stale record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'updated_at' => $crmObject->getAttribute('updated_at'), ]); $syncedObject = match (true) { $crmObject instanceof Lead => $crmService->syncLead($crmProviderId), $crmObject instanceof Account => $crmService->syncAccount($crmProviderId), $crmObject instanceof Contact => $crmService->syncContact($crmProviderId), $crmObject instanceof Opportunity => $crmService->syncOpportunity($crmProviderId), }; if ($syncedObject === null) { return $this->purgeStaleRecord($crmObject); } $syncedObject->touch(); $this->logger->info('[StaleRecordValidator] Record synced successfully', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, ]); return $syncedObject; } catch (HttpNotFoundException) { return $this->purgeStaleRecord($crmObject); } catch (Exception $e) { $this->logger->error('[StaleRecordValidator] Failed to sync record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'error' => $e->getMessage(), ]); return $crmObject; } } private function purgeStaleRecord(SyncableCrmObjectInterface $crmObject): null { $this->logger->info('[StaleRecordValidator] Record not found in remote CRM', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmObject->getCrmProviderId(), ]); $this->dispatcher->dispatch(new RemoteCrmRecordDeleted($crmObject)); return null; } }
More options for Exaplain <?php declare(strict_types=1); namespace Jiminny\Services\Crm\CrmObjects\Validators; use Exception; use Carbon\CarbonImmutable; use Illuminate\Contracts\Events\Dispatcher; use Jiminny\Contracts\Crm\SyncableCrmObjectInterface; use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface; use Jiminny\Events\Crm\RemoteCrmRecordDeleted; use Jiminny\Exceptions\HttpNotFoundException; use Jiminny\Models\Account; use Jiminny\Models\Contact; use Jiminny\Models\Lead; use Jiminny\Models\Opportunity; use Psr\Log\LoggerInterface; /** * Validate if a CRM record is stale. * * If a record hasn't been updated recently, we should test against the real CRM * to validate if this record still exists, or was deleted / purged. */ class StaleRecordValidator { /** * If a CRM entity hasn't been updated in more than 120 days, the object may be potentially stale */ private const int STALE_THRESHOLD_DAYS = 120; public function __construct( private readonly LoggerInterface $logger, private readonly Dispatcher $dispatcher ) { } public function filterStale( ?SyncableCrmObjectInterface $crmObjectCandidate, ?SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { if (! $crmObjectCandidate) { return null; } if (! $crmService) { return $crmObjectCandidate; } $thresholdDate = CarbonImmutable::now()->subDays(self::STALE_THRESHOLD_DAYS); if ($thresholdDate->isBefore($crmObjectCandidate->getAttribute('updated_at'))) { return $crmObjectCandidate; } return $this->syncPotentiallyStaleObject($crmObjectCandidate, $crmService); } private function syncPotentiallyStaleObject( SyncableCrmObjectInterface $crmObject, SyncCrmEntitiesInterface $crmService ): ?SyncableCrmObjectInterface { $crmProviderId = $crmObject->getCrmProviderId(); if (empty($crmProviderId)) { $this->logger->warning('[StaleRecordValidator] CRM object has empty crm_provider_id', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), ]); return $crmObject; } try { $this->logger->info('[StaleRecordValidator] Syncing potentially stale record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'updated_at' => $crmObject->getAttribute('updated_at'), ]); $syncedObject = match (true) { $crmObject instanceof Lead => $crmService->syncLead($crmProviderId), $crmObject instanceof Account => $crmService->syncAccount($crmProviderId), $crmObject instanceof Contact => $crmService->syncContact($crmProviderId), $crmObject instanceof Opportunity => $crmService->syncOpportunity($crmProviderId), }; if ($syncedObject === null) { return $this->purgeStaleRecord($crmObject); } $syncedObject->touch(); $this->logger->info('[StaleRecordValidator] Record synced successfully', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, ]); return $syncedObject; } catch (HttpNotFoundException) { return $this->purgeStaleRecord($crmObject); } catch (Exception $e) { $this->logger->error('[StaleRecordValidator] Failed to sync record', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmProviderId, 'error' => $e->getMessage(), ]); return $crmObject; } } private function purgeStaleRecord(SyncableCrmObjectInterface $crmObject): null { $this->logger->info('[StaleRecordValidator] Record not found in remote CRM', [ 'model' => get_class($crmObject), 'id' => $crmObject->getId(), 'crm_provider_id' => $crmObject->getCrmProviderId(), ]); $this->dispatcher->dispatch(new RemoteCrmRecordDeleted($crmObject)); return null; } }
Google Meet Speaker Data on Firefox
Google Meet Speaker Data on Firefox
More options for Google Meet Speaker Data on Firefox
Activity
Activity
Settings & help
Settings & help
Upgrade
Upgrade
Sofia, Bulgaria
From your IP address
•
Update location
Summarize page
Summarize page
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (33)
Pull requests
(
33
)
Agents
Agents
Actions
Actions
Wiki
Wiki...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9249
|
|
9248
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20820 es reindex stream model hydration by Vasi</tabTitle>” with “<selection>@@ -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This selection contains unit tests for the
LoadDocumentsAction
class, specifically focusing on the
loadDocuments
method. The tests verify how the system hydrates search engine document models from database entities, handling updates, deletions, and exceptions.
Core Functionality Tested
Core Functionality Tested
The tests use a mocked
EntityQueryBuilder
and anonymous
Searchable
classes to simulate different hydration scenarios:
Document Updates:
Verifies that active entities are correctly transformed into
Document
objects containing the expected ID and data (indexable attributes).
Document Deletions:
Ensures that entities flagged as
isDeleted
are processed for removal from the search index.
Mixed Results:
Validates the ability to process a collection containing both updateable and deletable entities in a single stream.
Empty Result Sets:
Confirms the system handles empty entity lists gracefully without errors.
Error Handling & Resilience
Error Handling & Resilience
Exception Catching:
A specific test case (
testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes
) ensures that if a single entity fails to hydrate due to an exception, the system:
Logs the error to
Sentry
using a
SyncActivityException
.
Continues processing other valid entities in the stream rather than failing the entire batch.
Technical Implementation Details
Technical Implementation Details
Mocking Strategy:
Uses
createMock
for dependencies and a custom
createEntityMock
helper to generate anonymous classes implementing the
Searchable
interface.
Assertions:
Utilizes PHPUnit assertions like
assertEquals
,
assertInstanceOf
, and
count
to verify the state of the returned result object.
Iterator Usage:
The
createQueryBuilderMock
uses an
ArrayIterator
to simulate a database cursor, reflecting the streaming nature of the hydration process.
Good response
Bad response
Redo
Share & export
Copy
Show more options
Enter a prompt for Gemini
encrypted
Enter a prompt for Gemini
encrypted
Open upload file menu
Tools
Open mode picker
Pro
Microphone
Send message
Your Jiminny chats aren’t used to improve our models. Gemini is AI. It can make mistakes, so double check it.
Your privacy & Gemini Opens in a new window
Your privacy & Gemini
Opens in a new window
Summarize page
Summarize page
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (33)
Pull requests
(
33
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (2)
Security and quality
(
2
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
Jy 20820 es reindex stream model hydration #12059 Edit title
Jy 20820 es reindex stream model hydration
#
12059
Edit title
Preview
Preview
Awaiting approval
Awaiting approval
Code
Code
Open
Vasil-Jiminny
Vasil-Jiminny
wants to merge 35 commits into
master
master
from
JY-20820-es-reindex-stream-model-hydration
JY-20820-es-reindex-stream-model-hydration
Copy head branch name to clipboard
Lines changed: 363 additions & 140 deletions
Conversation (8)
Conversation
(
8
)
Commits (35)
Commits
(
35
)
Checks (3)
Checks
(
3
)
Files changed (12)
Files changed
(
12
)
Pull Request Toolbar
Pull Request Toolbar
Collapse file tree
All commits
All commits
0 of 12 files viewed
Submit review
Submit
review
Open diff view settings
Open overview panel
Open comments panel
(
0
)
Filter files…
Filter options
File tree
File tree
app
Component
ElasticSearch/Contract
Searchable.php
Searchable.php
ES
Processor
Actions
LoadDocumentsAction.php
Traits
SkipActivityTrait.php
EntityQueryBuilder.php
EntityQueryBuilder.php
Repositories
EsResetActivityRepository.php
EsResetActivityRepository.php
ElasticSearchDocumentPartialUpdater.php...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9248
|
|
9247
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20820 es reindex stream model hydration by Vasi</tabTitle>” with “<selection>@@ -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Listen
Gemini said
Gemini said
This selection contains unit tests for the
LoadDocumentsAction
class, specifically focusing on the
loadDocuments
method. The tests verify how the system hydrates search engine document models from database entities, handling updates, deletions, and exceptions.
Core Functionality Tested
Core Functionality Tested
The tests use a mocked
EntityQueryBuilder
and anonymous
Searchable
classes to simulate different hydration scenarios:
Document Updates:
Verifies that active entities are correctly transformed into
Document
objects containing the expected ID and data (indexable attributes).
Document Deletions:
Ensures that entities flagged as
isDeleted
are processed for removal from the search index.
Mixed Results:
Validates the ability to process a collection containing both updateable and deletable entities in a single stream.
Empty Result Sets:
Confirms the system handles empty entity lists gracefully without errors.
Error Handling & Resilience
Error Handling & Resilience
Exception Catching:
A specific test case (
testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes
) ensures that if a single entity fails to hydrate due to an exception, the system:
Logs the error to
Sentry
using a
SyncActivityException
.
Continues processing other valid entities in the stream rather than failing the entire batch.
Technical Implementation Details
Technical Implementation Details
Mocking Strategy:
Uses
createMock
for dependencies and a custom
createEntityMock
helper to generate anonymous classes implementing the
Searchable
interface.
Assertions:
Utilizes PHPUnit assertions like
assertEquals
,
assertInstanceOf
, and
count
to verify the state of the returned result object.
Iterator Usage:
The
createQueryBuilderMock
uses an
ArrayIterator
to simulate a database cursor, reflecting the streaming nature of the hydration process.
Good response
Bad response
Redo
Share & export
Copy
Show more options
Enter a prompt for Gemini
encrypted
Enter a prompt for Gemini
encrypted
Open upload file menu
Tools
Open mode picker
Pro
Microphone
Send message
Your Jiminny chats aren’t used to improve our models. Gemini is AI. It can make mistakes, so double check it.
Your privacy & Gemini Opens in a new window
Your privacy & Gemini
Opens in a new window
Summarize page
Summarize page...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9247
|
|
9246
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected. Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
You said
I’m on page “<tabTitle>Jy 20820 es reindex stream model hydration by Vasi</tabTitle>” with “<selection>@@ -0,0 +1,266 @@1+queryBuilder = $this->createMock(EntityQueryBuilder::class);29+$this->action = new LoadDocumentsAction($this->queryBuilder);30+ }31+32+public function testLoadDocumentsWithEntitiesToUpdate(): void33+ {34+$entityIds = [1, 2, 3];35+$updateTarget = UpdateTarget::ACTIVITY;36+37+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);38+$entity2 = $this->createEntityMock(2, false, ['field2' => 'value2']);39+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);40+41+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3]);42+43+$this->queryBuilder44+ ->expects($this->once())45+ ->method('getEntityQuery')46+ ->with($updateTarget, $entityIds)47+ ->willReturn($queryBuilder);48+49+$result = $this->action->loadDocuments($updateTarget, $entityIds);50+51+$this->assertEquals(3, $result->getDocumentsToUpdate()->count());52+$this->assertEquals(0, $result->getDocumentsToDelete()->count());53+54+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();55+$this->assertInstanceOf(Document::class, $documentsToUpdate[0]);56+$this->assertEquals('1', $documentsToUpdate[0]->getId());57+$this->assertEquals(['field1' => 'value1'], $documentsToUpdate[0]->getData());58+ }59+60+public function testLoadDocumentsWithEntitiesToDelete(): void61+ {62+$entityIds = [1, 2];63+$updateTarget = UpdateTarget::OPPORTUNITY;64+65+$entity1 = $this->createEntityMock(1, true, []);66+$entity2 = $this->createEntityMock(2, true, []);67+68+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);69+70+$this->queryBuilder71+ ->expects($this->once())72+ ->method('getEntityQuery')73+ ->with($updateTarget, $entityIds)74+ ->willReturn($queryBuilder);75+76+$result = $this->action->loadDocuments($updateTarget, $entityIds);77+78+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());79+$this->assertEquals(2, $result->getDocumentsToDelete()->count());80+81+$documentsToDelete = $result->getDocumentsToDelete()->toArray();82+$this->assertEquals(1, $documentsToDelete[0]);83+$this->assertEquals(2, $documentsToDelete[1]);84+ }85+86+public function testLoadDocumentsWithMixedEntities(): void87+ {88+$entityIds = [1, 2, 3, 4];89+$updateTarget = UpdateTarget::ACTIVITY;90+91+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);92+$entity2 = $this->createEntityMock(2, true, []);93+$entity3 = $this->createEntityMock(3, false, ['field3' => 'value3']);94+$entity4 = $this->createEntityMock(4, true, []);95+96+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2, $entity3, $entity4]);97+98+$this->queryBuilder99+ ->expects($this->once())100+ ->method('getEntityQuery')101+ ->with($updateTarget, $entityIds)102+ ->willReturn($queryBuilder);103+104+$result = $this->action->loadDocuments($updateTarget, $entityIds);105+106+$this->assertEquals(2, $result->getDocumentsToUpdate()->count());107+$this->assertEquals(2, $result->getDocumentsToDelete()->count());108+109+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();110+$this->assertEquals('1', $documentsToUpdate[0]->getId());111+$this->assertEquals('3', $documentsToUpdate[1]->getId());112+113+$documentsToDelete = $result->getDocumentsToDelete()->toArray();114+$this->assertEquals(2, $documentsToDelete[0]);115+$this->assertEquals(4, $documentsToDelete[1]);116+ }117+118+public function testLoadDocumentsHandlesExceptionWhenBuildingIndexableAttributes(): void119+ {120+$entityIds = [1, 2];121+$updateTarget = UpdateTarget::ACTIVITY;122+123+$entity1 = $this->createEntityMock(1, false, ['field1' => 'value1']);124+$entity2 = $this->createEntityMockWithException(2, false);125+126+$queryBuilder = $this->createQueryBuilderMock([$entity1, $entity2]);127+128+$this->queryBuilder129+ ->expects($this->once())130+ ->method('getEntityQuery')131+ ->with($updateTarget, $entityIds)132+ ->willReturn($queryBuilder);133+134+$sentryMock = $this->createMock(SentryMock::class);135+$sentryMock->expects($this->once())136+ ->method('captureException')137+ ->with($this->isInstanceOf(SyncActivityException::class));138+139+ Sentry::swap($sentryMock);140+141+142+$result = $this->action->loadDocuments($updateTarget, $entityIds);143+144+$this->assertEquals(1, $result->getDocumentsToUpdate()->count());145+$this->assertEquals(0, $result->getDocumentsToDelete()->count());146+147+$documentsToUpdate = $result->getDocumentsToUpdate()->toArray();148+$this->assertEquals('1', $documentsToUpdate[0]->getId());149+ }150+151+public function testLoadDocumentsWithEmptyEntityList(): void152+ {153+$entityIds = [];154+$updateTarget = UpdateTarget::ACTIVITY;155+156+$queryBuilder = $this->createQueryBuilderMock([]);157+158+$this->queryBuilder159+ ->expects($this->once())160+ ->method('getEntityQuery')161+ ->with($updateTarget, $entityIds)162+ ->willReturn($queryBuilder);163+164+$result = $this->action->loadDocuments($updateTarget, $entityIds);165+166+$this->assertEquals(0, $result->getDocumentsToUpdate()->count());167+$this->assertEquals(0, $result->getDocumentsToDelete()->count());168+ }169+170+private function createEntityMock(int $id, bool $isDeleted, array $indexableAttributes): Searchable171+ {172+return new class ($id, $isDeleted, $indexableAttributes) implements Searchable {173+public function __construct(174+private int $id,175+private bool $isDeleted,176+private array $indexableAttributes177+ ) {178+ }179+180+public function getId(): int181+ {182+return $this->id;183+ }184+185+public function getIndex(): string186+ {187+return 'test_index';188+ }189+190+public function getSearchKey(): int191+ {192+return $this->id;193+ }194+195+public function getIndexableAttributes(): array196+ {197+return $this->indexableAttributes;198+ }199+200+public function isDeleted(): bool201+ {202+return $this->isDeleted;203+ }204+205+public function setRelations(array $relations): void206+ {207+ }208+ };209+ }210+211+private function createEntityMockWithException(int $id, bool $isDeleted): Searchable212+ {213+return new class ($id, $isDeleted) implements Searchable {214+public function __construct(215+private int $id,216+private bool $isDeleted217+ ) {218+ }219+220+public function getId(): int221+ {222+return $this->id;223+ }224+225+public function getIndex(): string226+ {227+return 'test_index';228+ }229+230+public function getSearchKey(): int231+ {232+return $this->id;233+ }234+235+public function getIndexableAttributes(): array236+ {237+throw new \RuntimeException('Failed to build indexable attributes');238+ }239+240+public function isDeleted(): bool241+ {242+return $this->isDeleted;243+ }244+245+public function setRelations(array $relations): void246+ {247+ }248+ };249+ }250+251+private function createQueryBuilderMock(array $entities): Builder&MockObject252+ {253+$queryBuilder = $this->createMock(Builder::class);254+$queryBuilder->method('cursor')->willReturn(new \ArrayIterator($entities));255+256+return $queryBuilder;257+ }258+}259+260+class SentryMock261+{262+public function captureException(Exception $e)263+ {264+// Do nothing.265+ }266+}</selection>” selected.
Please summarize the selection using precise and concise language. Use headers and bulleted lists in the summary, to make it scannable. Maintain the meaning and factual accuracy.
Expand
Gemini said
Gemini said
This selection contains unit tests for the
LoadDocumentsAction
class,
specifically focusing on the
loadDocuments
method.
The tests verify how the system hydrates search engine document models from database entities,
handling updates,
deletions,
and exceptions.
Core Functionality Tested
Core Functionality Tested
The tests use a mocked
EntityQueryBuilder
and anonymous
Searchable
classes to simulate different hydration scenarios:
Document Updates:
Verifies that active entities are correctly transformed into
Document
objects containing the expected ID and data (indexable attributes).
Document Deletions:
Ensures that entities flagged as
isDeleted
are processed for removal from the search index.
Mixed Results:
Validates the ability to process a collection containing both updateable and deletable entities in a single stream.
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. It can make mistakes, so double check it.
Your privacy & Gemini Opens in a new window
Your privacy & Gemini
Opens in a new window
Gemini replied
Summarize page
Summarize page
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (33)
Pull requests
(
33
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (2)
Security and quality
(
2
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
Jy 20820 es reindex stream model hydration #12059 Edit title
Jy 20820 es reindex stream model hydration
#
12059
Edit title
Preview
Preview
Awaiting approval
Awaiting approval
Code
Code
Open
Vasil-Jiminny
Vasil-Jiminny
wants to merge 35 commits into
master
master
from
JY-20820-es-reindex-stream-model-hydration
JY-20820-es-reindex-stream-model-hydration
Copy head branch name to clipboard
Lines changed: 363 additions & 140 deletions
Conversation (8)
Conversation
(
8
)
Commits (35)
Commits
(
35
)
Checks (3)
Checks
(
3
)
Files changed (12)
Files changed
(
12
)
Pull Request Toolbar
Pull Request Toolbar
Collapse file tree
All commits
All commits
0 of 12 files viewed
Submit review
Submit
review
Open diff view settings
Open overview panel
Open comments panel
(
0
)
Filter files…
Filter options
File tree
File tree
app
Component
ElasticSearch/Contract
Searchable.php
Searchable.php
ES
Processor
Actions
LoadDocumentsAction.php
Traits
SkipActivityTrait.php
EntityQueryBuilder.php
EntityQueryBuilder.php
Repositories
EsResetActivityRepository.php
EsResetActivityRepository.php
ElasticSearchDocumentPartialUpdater.php
ElasticSearchDocumentPartialUpdater.php
Console/Commands/Elasticsearch
AsyncUpdateEsActivities.php
AsyncUpdateEsActivities.php
AsyncUpdateEsEntities.php
AsyncUpdateEsEntities.php
Models/ElasticSearch
OpportunityElasticSearchTrait.php
OpportunityElasticSearchTrait.php
tests/Unit/Component/ES/Processor
Actions
LoadDocumentsActionTest.php
LoadDocumentsActionTest.php
Traits
SkipActivityTraitTest.php
SkipActivityTraitTest.php
EntityQueryBuilderTest.php
EntityQueryBuilderTest.php
Collapse file
app/Component/ElasticSearch/Contract/Searchable.php
app/Component/ElasticSearch/Contract/Searchable.php
app/Component/ElasticSearch/Contract/Searchable.php
Copy file name to clipboard
Expand all lines: app/Component/ElasticSearch/Contract/Searchable.php
Lines changed: 4 additions & 0 deletions
Not Viewed
Viewed
Comment on this file
More options
Original file line number
Original file line
Diff line number
Diff line change
@@ -18,4 +18,8 @@ public function getIndex(): string;
18
public
function...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9246
|
|
9245
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Close tab
New Tab
Customize sidebar
Close Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
AI Chat settings
Close...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9245
|
|
9244
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Close tab
New Tab...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9244
|
|
9243
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -18,4 +18,8 @@ public function getIndex(): string;
18
public
function
getSearchKey
():
int
;
18
public
function
getSearchKey
():
int
;
19
19
20
public
function
getIndexableAttributes
():
array
;
20
public
function
getIndexableAttributes
():
array
;
21
+
22
+
public
function
isDeleted
():
bool
;
23
+
24
+
public
function
setRelations
(
array
$
relations
);
21
}
25
}
Original file line number
@@ -18,4 +18,8 @@ public function getIndex(): string;
18
19
20
21
Original file line
@@ -18,4 +18,8 @@ public function getIndex(): string;
public
function
getSearchKey
():
int
;
public
function
getIndexableAttributes
():
array
;...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9243
|
|
9242
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Close tab
New Tab
Customize sidebar
Close Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
AI Chat settings
Close
Ask Gemini
Google Account: [EMAIL]
Summarize page
Summarize page
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (33)
Pull requests
(
33
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (2)
Security and quality
(
2
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
Jy 20820 es reindex stream model hydration #12059 Edit title
Jy 20820 es reindex stream model hydration
#
12059
Edit title
Preview
Preview
Awaiting approval
Awaiting approval
Code
Code
Open
Vasil-Jiminny
Vasil-Jiminny
wants to merge 35 commits into
master
master
from
JY-20820-es-reindex-stream-model-hydration
JY-20820-es-reindex-stream-model-hydration
Copy head branch name to clipboard
Lines changed: 363 additions & 140 deletions
Conversation (8)
Conversation
(
8
)
Commits (35)
Commits
(
35
)
Checks (3)
Checks
(
3
)
Files changed (12)
Files changed
(
12
)
Pull Request Toolbar
Pull Request Toolbar
Collapse file tree
All commits
All commits
0 of 12 files viewed
Submit review
Submit
review
Open diff view settings
Open overview panel
Open comments panel
(
0
)
Filter files…
Filter options
File tree
File tree
app
Component
ElasticSearch/Contract
Searchable.php
Searchable.php
ES
Processor
Actions
LoadDocumentsAction.php
Traits
SkipActivityTrait.php
EntityQueryBuilder.php
EntityQueryBuilder.php
Repositories
EsResetActivityRepository.php
EsResetActivityRepository.php
ElasticSearchDocumentPartialUpdater.php
ElasticSearchDocumentPartialUpdater.php
Console/Commands/Elasticsearch
AsyncUpdateEsActivities.php
AsyncUpdateEsActivities.php
AsyncUpdateEsEntities.php
AsyncUpdateEsEntities.php
Models/ElasticSearch
OpportunityElasticSearchTrait.php
OpportunityElasticSearchTrait.php
tests/Unit/Component/ES/Processor
Actions
LoadDocumentsActionTest.php
LoadDocumentsActionTest.php
Traits
SkipActivityTraitTest.php
SkipActivityTraitTest.php
EntityQueryBuilderTest.php
EntityQueryBuilderTest.php
Collapse file
app/Component/ElasticSearch/Contract/Searchable.php
app/Component/ElasticSearch/Contract/Searchable.php
app/Component/ElasticSearch/Contract/Searchable.php
Copy file name to clipboard
Expand all lines: app/Component/ElasticSearch/Contract/Searchable.php
Lines changed: 4 additions & 0 deletions...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9242
|
|
9241
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Close tab
New Tab
Customize sidebar
Close Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
AI Chat settings
Close
Ask Gemini
Summarize page
Summarize page
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search
Chat with Copilot
Open Copilot…
Create new...
All issues(g then i)
All pull requests
All repositories
You have unread notifications(g then n)
Open user navigation menu
Repository navigation
Repository navigation
Code
Code
Pull requests (33)
Pull requests
(
33
)
Agents
Agents
Actions
Actions
Wiki
Wiki
Security and quality (2)
Security and quality
(
2
)
Insights
Insights
Settings
Settings
Important update
Important update
On April 24 we'll start using GitHub Copilot interaction data for AI model training unless you opt out.
Review this update
Review this update
and manage your preferences in your
GitHub account settings
GitHub account settings
.
Dismiss banner
Jy 20820 es reindex stream model hydration #12059 Edit title
Jy 20820 es reindex stream model hydration
#
12059
Edit title
Preview
Preview
Awaiting approval
Awaiting approval
Code
Code
Open
Vasil-Jiminny
Vasil-Jiminny
wants to merge 35 commits into
master
master
from
JY-20820-es-reindex-stream-model-hydration
JY-20820-es-reindex-stream-model-hydration
Copy head branch name to clipboard
Lines changed: 363 additions & 140 deletions
Conversation (8)
Conversation
(
8
)
Commits (35)
Commits
(
35
)
Checks (3)
Checks
(
3
)
Files changed (12)
Files changed
(
12
)
Pull Request Toolbar
Pull Request Toolbar
Collapse file tree
All commits
All commits...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9241
|
|
9240
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -18,4 +18,8 @@ public function getIndex(): string;
18
public
function
getSearchKey
():
int
;
18
public
function
getSearchKey
():
int
;
19
19
20
public
function
getIndexableAttributes
():
array
;
20
public
function
getIndexableAttributes
():
array
;
21...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9240
|
|
9239
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS] -18,4 +18,8 @@ public function getIndex(): string;
18
public
function
getSearchKey
():
int
;
18
public
function
getSearchKey
():
int
;
19
19
20
public
function
getIndexableAttributes
():
array
;
20
public...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9239
|
|
9238
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Close tab
New Tab...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059/changes
|
9238
|
|
9237
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
Vasil-Jiminny commented 1 hour ago •
Vasil-Jiminny
Vasil-Jiminny
commented
1 hour ago
1 hour ago
•
edited
edited
JIRA: JY-20820
JIRA:
JY-20820
JY-20820
Changes:
Changes:
Load documents for reindexing by streaming raw data into a single model, that is hydrated, extracts indexing data, and is then destroyed. Previously a batch of 100 models were loaded, with a massive overhead just for keeping a collection of models
Move Email filters from LoadDocuments to db query. Initially email filters were added to LoadDocuments, so that when we find an email for indexing, we send a "delete document" command to ES for that document. This leads to self-cleanup over time. Now it's unnecessary.
Since introducing 'es-update' worker group with long running processes, start command 'runForSeconds' is not necessary anymore. Dropped.
WorkerId is automatically generated.
SkipActivityTrait is deleted.
Tests are updated.
EntityQueryBuilder is now instantiable. Methods are called from instance.
ElasticSearchPartialUpdate is marked as deprecated.
Add or remove reactions
Vasil-Jiminny
Vasil-Jiminny
and others
added
28
commits
yesterday
yesterday
@Vasil-Jiminny
Add "isDeleted" accessor methods for usage in building ES payload.
Add "isDeleted" accessor methods for usage in building ES payload.
adc04ba
adc04ba
@Vasil-Jiminny
Remove method "isDeleted" from ActivityElasticSearchTrait as it's alr…
Remove method "isDeleted" from ActivityElasticSearchTrait as it's alr…
…
3b39a46
3b39a46
@Vasil-Jiminny
Add isDeleted to the Searchable contract.
Add isDeleted to the Searchable contract.
3ca67a0
3ca67a0
@Vasil-Jiminny
Move the logic of Skip Emails into the elastic search activity reposi…
Move the logic of Skip Emails into the elastic search activity reposi…
…
c7b2fb5
c7b2fb5
@Vasil-Jiminny
Since skipping emails is now delegated to the DB, we can remove artif…
Since skipping emails is now delegated to the DB, we can remove artif…
…
470e119
470e119
@Vasil-Jiminny
Refactor LoadActivityAction to use a cursor iterator over objects. Me…
Refactor LoadActivityAction to use a cursor iterator over objects. Me…
…
a7eb43f
a7eb43f
@Vasil-Jiminny
SkipActivityTrait and corresponding test are now deleted.
SkipActivityTrait and corresponding test are now deleted.
0ed71ff
0ed71ff
@Vasil-Jiminny
Code style improvement.
Code style improvement.
79e7ee3
79e7ee3
@Vasil-Jiminny
Added comments explaining the changes in Load Documents.
Added comments explaining the changes in Load Documents.
b6e9c6d
b6e9c6d
@Vasil-Jiminny
Minor code style changes.
Minor code style changes.
07fa98c
07fa98c
@Vasil-Jiminny
EntityQueryBuilderTest is updated to not use static methods.
EntityQueryBuilderTest is updated to not use static methods.
565a571
565a571
@Vasil-Jiminny
"Final readonly" restrictions removed from EntityQueryBuilder class.
"Final readonly" restrictions removed from EntityQueryBuilder class.
46590e5
46590e5
@Vasil-Jiminny
Make EntityQuertBuilder instantiable.
Make EntityQuertBuilder instantiable.
6d34ebe
6d34ebe
@Vasil-Jiminny
LoadDocumentsAction instantiates the queryBuilder.
LoadDocumentsAction instantiates the queryBuilder.
27277b8
27277b8
@Vasil-Jiminny
LoadDocumentsActions covered with a unit test (finally).
LoadDocumentsActions covered with a unit test (finally).
8 / 10 checks OK
e2693eb
e2693eb
@Vasil-Jiminny
Add an explanation why ElasticSearchDocumentPartialUpdater should not…
Add an explanation why ElasticSearchDocumentPartialUpdater should not…
…
6da0165
6da0165
@Vasil-Jiminny
Better testing mechanism for Sentry.
Better testing mechanism for Sentry.
8 / 10 checks OK
80a2057
80a2057
@Vasil-Jiminny
Fix a test issue in EntityQueryBuilderTest
Fix a test issue in EntityQueryBuilderTest
12 / 12 checks OK
46330fa
46330fa
@Vasil-Jiminny
Allow selection of chunks with up to 500 items.
Allow selection of chunks with up to 500 items.
21 / 21 checks OK
f7c53fa
f7c53fa
@Vasil-Jiminny
Drop the "ID" option from update-entities command. WorkerId will be a…
Drop the "ID" option from update-entities command. WorkerId will be a…
…
8273321
8273321
@Vasil-Jiminny
Directly use the stringified UUID instead of the object.
Directly use the stringified UUID instead of the object.
6f50572
6f50572
@Vasil-Jiminny
Drop the "runForSeconds" paramter.
Drop the "runForSeconds" paramter.
4edceca
4edceca
@Vasil-Jiminny
Drop the "runForSeconds" paramter. Run a command until "shouldStop" o…
Drop the "runForSeconds" paramter. Run a command until "shouldStop" o…
…
ed12e2b
ed12e2b
@Vasil-Jiminny
Partially restore the "runForSeconds" attribute, until it's removed f…
Partially restore the "runForSeconds" attribute, until it's removed f…
…
21 / 21 checks OK
f029263
f029263
@Vasil-Jiminny
Revert chunk size changes.
Revert chunk size changes.
21 / 21 checks OK
6ed9e2b
6ed9e2b
@Vasil-Jiminny
Remove an unnecessary whitespace.
Remove an unnecessary whitespace.
9acb137
9acb137
@Vasil-Jiminny
Merge branch '
Merge branch '
JY-20808
JY-20808
-low-priority-indexing-queue' into
-low-priority-indexing-queue' into
JY-20820
JY-20820
-es-…
-es-…
…
8 / 10 checks OK
37241b6
37241b6
@Vasil-Jiminny
Merge branch 'master' into
Merge branch 'master' into
JY-20820
JY-20820
-es-reindex-stream-model-hydration
-es-reindex-stream-model-hydration
Verified
Verified
4 / 6 checks OK
b2f4fc9
b2f4fc9
@Vasil-Jiminny
This comment was marked as outdated. Show comment
This comment was marked as outdated.
This comment was marked as outdated.
Show comment
@claude
This comment was marked as outdated. Show comment
This comment was marked as outdated.
This comment was marked as outdated.
Show comment
Vasil-Jiminny
Vasil-Jiminny
added
4
commits
1 hour ago
1 hour ago
@Vasil-Jiminny...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059
|
9237
|
|
9236
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
Vasil-Jiminny commented 1 hour ago •
Vasil-Jiminny
Vasil-Jiminny
commented
1 hour ago
1 hour ago
•
edited
edited
JIRA: JY-20820
JIRA:
JY-20820
JY-20820
Changes:
Changes:
Load documents for reindexing by streaming raw data into a single model, that is hydrated, extracts indexing data, and is then destroyed. Previously a batch of 100 models were loaded, with a massive overhead just for keeping a collection of models
Move Email filters from LoadDocuments to db query. Initially email filters were added to LoadDocuments, so that when we find an email for indexing, we send a "delete document" command to ES for that document. This leads to self-cleanup over time. Now it's unnecessary.
Since introducing 'es-update' worker group with long running processes, start command 'runForSeconds' is not necessary anymore. Dropped.
WorkerId is automatically generated.
SkipActivityTrait is deleted.
Tests are updated.
EntityQueryBuilder is now instantiable. Methods are called from instance.
ElasticSearchPartialUpdate is marked as deprecated.
Add or remove reactions
Vasil-Jiminny
Vasil-Jiminny
and others
added
28
commits
yesterday
yesterday
@Vasil-Jiminny
Add "isDeleted" accessor methods for usage in building ES payload.
Add "isDeleted" accessor methods for usage in building ES payload.
adc04ba
adc04ba
@Vasil-Jiminny
Remove method "isDeleted" from ActivityElasticSearchTrait as it's alr…
Remove method "isDeleted" from ActivityElasticSearchTrait as it's alr…
…
3b39a46
3b39a46
@Vasil-Jiminny
Add isDeleted to the Searchable contract.
Add isDeleted to the Searchable contract.
3ca67a0
3ca67a0
@Vasil-Jiminny
Move the logic of Skip Emails into the elastic search activity reposi…
Move the logic of Skip Emails into the elastic search activity reposi…
…
c7b2fb5
c7b2fb5
@Vasil-Jiminny
Since skipping emails is now delegated to the DB, we can remove artif…
Since skipping emails is now delegated to the DB, we can remove artif…
…
470e119
470e119
@Vasil-Jiminny
Refactor LoadActivityAction to use a cursor iterator over objects. Me…
Refactor LoadActivityAction to use a cursor iterator over objects. Me…
…
a7eb43f
a7eb43f
@Vasil-Jiminny
SkipActivityTrait and corresponding test are now deleted.
SkipActivityTrait and corresponding test are now deleted.
0ed71ff
0ed71ff
@Vasil-Jiminny
Code style improvement.
Code style improvement.
79e7ee3
79e7ee3
@Vasil-Jiminny
Added comments explaining the changes in Load Documents.
Added comments explaining the changes in Load Documents.
b6e9c6d
b6e9c6d
@Vasil-Jiminny
Minor code style changes.
Minor code style changes.
07fa98c
07fa98c
@Vasil-Jiminny
EntityQueryBuilderTest is updated to not use static methods.
EntityQueryBuilderTest is updated to not use static methods.
565a571
565a571
@Vasil-Jiminny
"Final readonly" restrictions removed from EntityQueryBuilder class.
"Final readonly" restrictions removed from EntityQueryBuilder class.
46590e5
46590e5
@Vasil-Jiminny
Make EntityQuertBuilder instantiable.
Make EntityQuertBuilder instantiable.
6d34ebe
6d34ebe
@Vasil-Jiminny
LoadDocumentsAction instantiates the queryBuilder.
LoadDocumentsAction instantiates the queryBuilder.
27277b8
27277b8
@Vasil-Jiminny
LoadDocumentsActions covered with a unit test (finally).
LoadDocumentsActions covered with a unit test (finally).
8 / 10 checks OK
e2693eb
e2693eb
@Vasil-Jiminny
Add an explanation why ElasticSearchDocumentPartialUpdater should not…
Add an explanation why ElasticSearchDocumentPartialUpdater should not…
…
6da0165
6da0165
@Vasil-Jiminny
Better testing mechanism for Sentry.
Better testing mechanism for Sentry.
8 / 10 checks OK
80a2057
80a2057
@Vasil-Jiminny
Fix a test issue in EntityQueryBuilderTest
Fix a test issue in EntityQueryBuilderTest
12 / 12 checks OK
46330fa
46330fa
@Vasil-Jiminny
Allow selection of chunks with up to 500 items.
Allow selection of chunks with up to 500 items.
21 / 21 checks OK
f7c53fa
f7c53fa
@Vasil-Jiminny
Drop the "ID" option from update-entities command. WorkerId will be a…
Drop the "ID" option from update-entities command. WorkerId will be a…
…
8273321
8273321
@Vasil-Jiminny
Directly use the stringified UUID instead of the object.
Directly use the stringified UUID instead of the object.
6f50572
6f50572
@Vasil-Jiminny
Drop the "runForSeconds" paramter.
Drop the "runForSeconds" paramter.
4edceca
4edceca
@Vasil-Jiminny
Drop the "runForSeconds" paramter. Run a command until "shouldStop" o…
Drop the "runForSeconds" paramter. Run a command until "shouldStop" o…
…
ed12e2b
ed12e2b
@Vasil-Jiminny
Partially restore the "runForSeconds" attribute, until it's removed f…
Partially restore the "runForSeconds" attribute, until it's removed f…
…
21 / 21 checks OK
f029263
f029263
@Vasil-Jiminny
Revert chunk size changes.
Revert chunk size changes.
21 / 21 checks OK
6ed9e2b
6ed9e2b
@Vasil-Jiminny
Remove an unnecessary whitespace.
Remove an unnecessary whitespace.
9acb137
9acb137
@Vasil-Jiminny
Merge branch '
Merge branch '
JY-20808
JY-20808
-low-priority-indexing-queue' into
-low-priority-indexing-queue' into
JY-20820
JY-20820
-es-…
-es-…
…
8 / 10 checks OK
37241b6
37241b6
@Vasil-Jiminny
Merge branch 'master' into
Merge branch 'master' into
JY-20820
JY-20820
-es-reindex-stream-model-hydration
-es-reindex-stream-model-hydration
Verified
Verified
4 / 6 checks OK
b2f4fc9
b2f4fc9
@Vasil-Jiminny
This comment was marked as outdated. Show comment
This comment was marked as outdated.
This comment was marked as outdated.
Show comment
@claude
This comment was marked as outdated. Show comment
This comment was marked as outdated.
This comment was marked as outdated.
Show comment
Vasil-Jiminny
Vasil-Jiminny
added
4
commits
1 hour ago
1 hour ago
@Vasil-Jiminny
EntityQueryBuilderTest is improved....
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059
|
9236
|
|
9235
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
Vasil-Jiminny commented 1 hour ago •
Vasil-Jiminny
Vasil-Jiminny
commented
1 hour ago
1 hour ago
•
edited
edited
JIRA: JY-20820
JIRA:
JY-20820
JY-20820
Changes:
Changes:
Load documents for reindexing by streaming raw data into a single model, that is hydrated, extracts indexing data, and is then destroyed. Previously a batch of 100 models were loaded, with a massive overhead just for keeping a collection of models
Move Email filters from LoadDocuments to db query. Initially email filters were added to LoadDocuments, so that when we find an email for indexing, we send a "delete document" command to ES for that document. This leads to self-cleanup over time. Now it's unnecessary.
Since introducing 'es-update' worker group with long running processes, start command 'runForSeconds' is not necessary anymore. Dropped.
WorkerId is automatically generated.
SkipActivityTrait is deleted.
Tests are updated.
EntityQueryBuilder is now instantiable. Methods are called from instance.
ElasticSearchPartialUpdate is marked as deprecated.
Add or remove reactions
Vasil-Jiminny
Vasil-Jiminny
and others
added
28
commits
yesterday
yesterday
@Vasil-Jiminny
Add "isDeleted" accessor methods for usage in building ES payload.
Add "isDeleted" accessor methods for usage in building ES payload.
adc04ba
adc04ba
@Vasil-Jiminny
Remove method "isDeleted" from ActivityElasticSearchTrait as it's alr…
Remove method "isDeleted" from ActivityElasticSearchTrait as it's alr…
…
3b39a46
3b39a46
@Vasil-Jiminny
Add isDeleted to the Searchable contract.
Add isDeleted to the Searchable contract.
3ca67a0
3ca67a0
@Vasil-Jiminny
Move the logic of Skip Emails into the elastic search activity reposi…
Move the logic of Skip Emails into the elastic search activity reposi…
…
c7b2fb5
c7b2fb5
@Vasil-Jiminny
Since skipping emails is now delegated to the DB, we can remove artif…
Since skipping emails is now delegated to the DB, we can remove artif…
…
470e119
470e119
@Vasil-Jiminny
Refactor LoadActivityAction to use a cursor iterator over objects. Me…
Refactor LoadActivityAction to use a cursor iterator over objects. Me…
…
a7eb43f
a7eb43f
@Vasil-Jiminny
SkipActivityTrait and corresponding test are now deleted.
SkipActivityTrait and corresponding test are now deleted.
0ed71ff
0ed71ff
@Vasil-Jiminny
Code style improvement.
Code style improvement.
79e7ee3
79e7ee3
@Vasil-Jiminny
Added comments explaining the changes in Load Documents.
Added comments explaining the changes in Load Documents.
b6e9c6d
b6e9c6d
@Vasil-Jiminny
Minor code style changes.
Minor code style changes.
07fa98c
07fa98c
@Vasil-Jiminny
EntityQueryBuilderTest is updated to not use static methods.
EntityQueryBuilderTest is updated to not use static methods.
565a571
565a571
@Vasil-Jiminny
"Final readonly" restrictions removed from EntityQueryBuilder class.
"Final readonly" restrictions removed from EntityQueryBuilder class.
46590e5
46590e5
@Vasil-Jiminny
Make EntityQuertBuilder instantiable.
Make EntityQuertBuilder instantiable.
6d34ebe
6d34ebe
@Vasil-Jiminny
LoadDocumentsAction instantiates the queryBuilder.
LoadDocumentsAction instantiates the queryBuilder.
27277b8
27277b8
@Vasil-Jiminny
LoadDocumentsActions covered with a unit test (finally).
LoadDocumentsActions covered with a unit test (finally).
8 / 10 checks OK
e2693eb
e2693eb
@Vasil-Jiminny
Add an explanation why ElasticSearchDocumentPartialUpdater should not…
Add an explanation why ElasticSearchDocumentPartialUpdater should not…
…
6da0165
6da0165
@Vasil-Jiminny
Better testing mechanism for Sentry.
Better testing mechanism for Sentry.
8 / 10 checks OK
80a2057
80a2057
@Vasil-Jiminny
Fix a test issue in EntityQueryBuilderTest
Fix a test issue in EntityQueryBuilderTest
12 / 12 checks OK
46330fa
46330fa
@Vasil-Jiminny
Allow selection of chunks with up to 500 items.
Allow selection of chunks with up to 500 items.
21 / 21 checks OK
f7c53fa
f7c53fa
@Vasil-Jiminny
Drop the "ID" option from update-entities command. WorkerId will be a…
Drop the "ID" option from update-entities command. WorkerId will be a…
…
8273321
8273321
@Vasil-Jiminny
Directly use the stringified UUID instead of the object.
Directly use the stringified UUID instead of the object.
6f50572
6f50572
@Vasil-Jiminny
Drop the "runForSeconds" paramter.
Drop the "runForSeconds" paramter.
4edceca
4edceca
@Vasil-Jiminny
Drop the "runForSeconds" paramter. Run a command until "shouldStop" o…
Drop the "runForSeconds" paramter. Run a command until "shouldStop" o…
…
ed12e2b
ed12e2b
@Vasil-Jiminny
Partially restore the "runForSeconds" attribute, until it's removed f…
Partially restore the "runForSeconds" attribute, until it's removed f…
…
21 / 21 checks OK
f029263
f029263
@Vasil-Jiminny
Revert chunk size changes.
Revert chunk size changes.
21 / 21 checks OK
6ed9e2b
6ed9e2b
@Vasil-Jiminny
Remove an unnecessary whitespace.
Remove an unnecessary whitespace.
9acb137
9acb137
@Vasil-Jiminny
Merge branch '
Merge branch '
JY-20808
JY-20808
-low-priority-indexing-queue' into
-low-priority-indexing-queue' into
JY-20820
JY-20820
-es-…
-es-…
…
8 / 10 checks OK
37241b6
37241b6
@Vasil-Jiminny
Merge branch 'master' into
Merge branch 'master' into
JY-20820
JY-20820
-es-reindex-stream-model-hydration
-es-reindex-stream-model-hydration
Verified
Verified
4 / 6 checks OK
b2f4fc9
b2f4fc9
@Vasil-Jiminny
This comment was marked as outdated. Show comment
This comment was marked as outdated.
This comment was marked as outdated.
Show comment
@claude
This comment was marked as outdated. Show comment
This comment was marked as outdated.
This comment was marked as outdated.
Show comment
Vasil-Jiminny
Vasil-Jiminny
added
4
commits
1 hour ago
1 hour ago
@Vasil-Jiminny
EntityQueryBuilderTest is improved.
EntityQueryBuilderTest is improved....
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059
|
9235
|
|
9234
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST [URL_WITH_CREDENTIALS]
Show options
Vasil-Jiminny commented 1 hour ago •
Vasil-Jiminny
Vasil-Jiminny
commented
1 hour ago
1 hour ago
•
edited
edited
JIRA: JY-20820
JIRA:
JY-20820
JY-20820
Changes:
Changes:
Load documents for reindexing by streaming raw data into a single model, that is hydrated, extracts indexing data, and is then destroyed. Previously a batch of 100 models were loaded, with a massive overhead just for keeping a collection of models
Move Email filters from LoadDocuments to db query. Initially email filters were added to LoadDocuments, so that when we find an email for indexing, we send a "delete document" command to ES for that document. This leads to self-cleanup over time. Now it's unnecessary.
Since introducing 'es-update' worker group with long running processes, start command 'runForSeconds' is not necessary anymore. Dropped.
WorkerId is automatically generated.
SkipActivityTrait is deleted.
Tests are updated.
EntityQueryBuilder is now instantiable. Methods are called from instance.
ElasticSearchPartialUpdate is marked as deprecated.
Add or remove reactions
Vasil-Jiminny
Vasil-Jiminny
and others
added
28
commits
yesterday
yesterday
@Vasil-Jiminny
Add "isDeleted" accessor methods for usage in building ES payload.
Add "isDeleted" accessor methods for usage in building ES payload.
adc04ba
adc04ba
@Vasil-Jiminny
Remove method "isDeleted" from ActivityElasticSearchTrait as it's alr…
Remove method "isDeleted" from ActivityElasticSearchTrait as it's alr…
…
3b39a46
3b39a46
@Vasil-Jiminny
Add isDeleted to the Searchable contract.
Add isDeleted to the Searchable contract.
3ca67a0
3ca67a0
@Vasil-Jiminny
Move the logic of Skip Emails into the elastic search activity reposi…
Move the logic of Skip Emails into the elastic search activity reposi…
…
c7b2fb5
c7b2fb5
@Vasil-Jiminny
Since skipping emails is now delegated to the DB, we can remove artif…
Since skipping emails is now delegated to the DB, we can remove artif…
…
470e119
470e119
@Vasil-Jiminny
Refactor LoadActivityAction to use a cursor iterator over objects. Me…
Refactor LoadActivityAction to use a cursor iterator over objects. Me…
…
a7eb43f
a7eb43f
@Vasil-Jiminny
SkipActivityTrait and corresponding test are now deleted.
SkipActivityTrait and corresponding test are now deleted.
0ed71ff
0ed71ff
@Vasil-Jiminny
Code style improvement.
Code style improvement.
79e7ee3
79e7ee3
@Vasil-Jiminny
Added comments explaining the changes in Load Documents.
Added comments explaining the changes in Load Documents.
b6e9c6d
b6e9c6d
@Vasil-Jiminny
Minor code style changes.
Minor code style changes.
07fa98c
07fa98c
@Vasil-Jiminny
EntityQueryBuilderTest is updated to not use static methods.
EntityQueryBuilderTest is updated to not use static methods.
565a571
565a571
@Vasil-Jiminny
"Final readonly" restrictions removed from EntityQueryBuilder class.
"Final readonly" restrictions removed from EntityQueryBuilder class.
46590e5
46590e5
@Vasil-Jiminny
Make EntityQuertBuilder instantiable.
Make EntityQuertBuilder instantiable.
6d34ebe
6d34ebe
@Vasil-Jiminny
LoadDocumentsAction instantiates the queryBuilder.
LoadDocumentsAction instantiates the queryBuilder.
27277b8
27277b8
@Vasil-Jiminny
LoadDocumentsActions covered with a unit test (finally).
LoadDocumentsActions covered with a unit test (finally).
8 / 10 checks OK
e2693eb
e2693eb
@Vasil-Jiminny
Add an explanation why ElasticSearchDocumentPartialUpdater should not…
Add an explanation why ElasticSearchDocumentPartialUpdater should not…
…
6da0165
6da0165
@Vasil-Jiminny
Better testing mechanism for Sentry.
Better testing mechanism for Sentry.
8 / 10 checks OK
80a2057
80a2057
@Vasil-Jiminny
Fix a test issue in EntityQueryBuilderTest
Fix a test issue in EntityQueryBuilderTest
12 / 12 checks OK
46330fa
46330fa
@Vasil-Jiminny
Allow selection of chunks with up to 500 items.
Allow selection of chunks with up to 500 items.
21 / 21 checks OK
f7c53fa
f7c53fa
@Vasil-Jiminny
Drop the "ID" option from update-entities command. WorkerId will be a…
Drop the "ID" option from update-entities command. WorkerId will be a…
…
8273321
8273321
@Vasil-Jiminny
Directly use the stringified UUID instead of the object.
Directly use the stringified UUID instead of the object.
6f50572
6f50572
@Vasil-Jiminny
Drop the "runForSeconds" paramter.
Drop the "runForSeconds" paramter.
4edceca
4edceca
@Vasil-Jiminny
Drop the "runForSeconds" paramter. Run a command until "shouldStop" o…
Drop the "runForSeconds" paramter. Run a command until "shouldStop" o…
…
ed12e2b
ed12e2b
@Vasil-Jiminny
Partially restore the "runForSeconds" attribute, until it's removed f…
Partially restore the "runForSeconds" attribute, until it's removed f…
…
21 / 21 checks OK
f029263
f029263
@Vasil-Jiminny...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059
|
9234
|
|
9233
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059
|
9233
|
|
9232
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to content
Skip to content
Open menu
Homepage (g then d)
jiminny
jiminny
app
app
Search or jump to…
Type
/
to search...
|
Firefox
|
Jy 20820 es reindex stream model hydration by Vasi Jy 20820 es reindex stream model hydration by Vasil-Jiminny · Pull Request #12059 · jiminny/app — Work...
|
github.com/jiminny/app/pull/12059
|
9232
|
|
9231
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Vasil Vasilev
Nikolay Ivanov
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
James Graham
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Add canvas
Add canvas
Files
Files
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Apr 28th at 4:48:37 PM
4:48 PM
Васко, ти знаеш ли как да добавим postmark key за QAI някъде във Амазон, вече credentials не ги държим в env
Vasil Vasilev
Apr 28th at 4:48:49 PM
4:48 PM
оу, не
Apr 28th at 4:48:50 PM
4:48
нямам идея
Apr 28th at 4:49:02 PM
4:49
Никога не ми се е налагало да работя с тоя Postmark
Lukas Kovalik
Apr 28th at 4:49:27 PM
4:49 PM
то по скоро Amazon credentials ми е въпрос
Vasil Vasilev
Apr 28th at 4:51:31 PM
4:51 PM
a, ти искаш в amazon да добавим ключ за достъп до QAi ?
Apr 28th at 4:51:38 PM
4:51
Вес се грижи за тея неща
Apr 28th at 4:52:06 PM
4:52
дори не съм сигурен дали тоя ключ не трябва да бъде в CircleCI при билда на имиджа
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Apr 28th at 4:52:10 PM
4:52
т.е.
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Apr 28th at 4:52:12 PM
4:52
пак не знам
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Lukas Kovalik
Apr 28th at 4:53:41 PM
4:53 PM
ок, ще питам Вес, мерси
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Vasil Vasilev
Apr 28th at 5:00:16 PM
5:00 PM
моля
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Vasil Vasilev
Today at 2:52:43 PM
2:52 PM
Лукаш, привет
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 2:52:48 PM
2:52
хвърли моля те едно око тука
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 2:52:49 PM
2:52
https://github.com/jiminny/app/pull/12059
https://github.com/jiminny/app/pull/12059
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 2:53:03 PM
2:53
опитвам се да оптимизирам процеса по индексиране на активитита за ЕС
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 2:54:16 PM
2:54
идеята е да намаля паметта която се ползва за да се генерира един бач от 100 активитита
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Today at 2:54:35 PM
2:54
и после да увелича размера на бачовете, за да имаме по малко blocking операции в ЕС, като реиндексира
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Channel
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
TDlOIDMoreSlackEditJiminny ...i contusion-clinid# curiosity lab# engineeringii generall#jiminny-bg# olatform-tickets# product launches# randomt relenced# sofia-office# support# thank-yous#t the people of iimi.^? Direct messagesa Vasil VasileyAneliva Angelova. NikolavVankov Stelivan Georoiev3 Aneliva Aggel.8 Stovan Taney €n Stefka Stovanova( VodAneliva Angelova& James Grahame Lukas Kovalik v... 0#: Apps8 Toast© PavloadBuilder.ohp(C) Profile, ohoC) @uervBuilder.ohoC) @uerv.andler.ohoC) @uerviterator nhol(C) @uervResults.nhn(C) SvncRatchRedicServM TraitsC. RaseClient nhn(e CachodCrm CorvicaNaec00 Crm AativitvDrovidorintoHistoryWindowHeldDescribe what you are looking forMoreимиджаlukas Kovalk 4.62pM• ое ше питам Rec мерсиVasil Vasilev X 5:00 PMVasil VasilevX 2:52 PMуеіюли маля те елно ско тука.httns-//aithub.com/fiminnv/ann/null/12059опитвам се ла оптимизирам процеса по.инлексипане на активитита за Е0илеята е ла намаля паметта която сеползва за ла се генерира елин бач от 100за ла имаме по малко blockine операциив Ее като пеинлексипаMessage Vasil Vasilev Be back soon. Late….RemotesntitvLookuniinterface)VenifvTaskExistsIntenface95use ResolveComnanvNameRvEmailTraituse SvncCrmEntitiesTnait:use WniteCrmTnait.uco Annontuni+vSvneTnai+.private const string LOG DATE FORMPlatform Sprint 3 Q2 - Platform Te-7 Service-Desk - Queues - Platforng Jy 20807 check various issues witA Feed - liminny - SentrAIy-20918 move ack liminny renortu Userpilot | Ask Jiminny Report Ger@ Jy-20773 fix user pilot tracking of* Problem loading page9 liminnv#1 Now TofAl Features | Datadog6) Pinelines - fiminnvlann+ New Tat!08Setting:Issues / Re APP-1EEDID: e72ac6cc12 hours ago JSON• SevenShores Hubspot Exceptions BadRequestv GuzZleHtto Exception ClientExceptionClient error:POST https://api.hubapi.com/crm/v3/objects/contact/search @* resulted in a429 Too Many Requests* response:{"status":"error", "message":"You have reached your secondly limit.", "errorType":"RATE_LIMIT", "correlationId":"019e04ea-a (truncated...)aenericCrashed in non-app:/vendor/guzzlehttp/guzzle/src/Exception/RequestException.php:111 in GuzzleHttp\Exception\RequestException::create/aon/Services/Crm/Hubsnot/Padination/HubsootPadinationService.nho:162_in_JiminnvlServices|Crm|Hubsoot\ Pagination\HubsnotPadinationServicexexecuteSearchReauest/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:51 in Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService::getPaginatedDataGeneratorlapo/Services/Crm//Hubspot/Client.oho:94 in Jiminny Services Crm Hubspot Client«aetPacinatedDatal/app/Services/Crm/Hubspot/Service.php:1212 in Jiminny|Services\Crm\Hubspot\Service::Jiminny\Services\Crm\Hubspot\(closure)Called from: /vendor/laravel/framework/src/llluminate/Cache/Renositorv.nhn:564inllluminate|Cache\Renositorv.rememberlann/Services/Crm/Hubsnot /Service nhn-1206 in Jiminnv|Services|Crm|Hubsnot|Service«matchRvName/app/Services/Crm/CachedCrmServiceDecorator.php:167 in Jiminny|Services\Crm\CachedCrmServiceDecorator::matchByName/app/Services/Crm/CrmActivityService.php:139 in Jiminny\Services\Crm\CrmActivityService:.updateParticipantsCrmData/app/Services/Crm//CrmActivityService.oho:81 in JiminnyServices Crm_CrmActivityService:updateCrmDatalapo/Jobs/Crm/MatchActivitvCrmData.oho:107inJiminnv|Jobs|Crm.MatchActivitvCrmData:Jiminnv|Jobs\Crm\closurelCalled from:/vendor/laravel/framework/src/llluminate/Database/Concerns/ManaaesTransactions.oho:35 in llluminate\ Database Connection:transactionlapo/Jobs/Crm/MatchActivitvCrmData.ohn:87inJiminnv|Jobs\Crm|MatchActivitvCrmData«handleCalled from: /vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php:36 in Illuminate\Container\BoundMethod:IIluminate\Container\(closure)Called from:/vendor/laravel/framework/src/llluminate/Queue/Worker.ohp:435 in Illuminate Queue Worker-runJobv Trace Preview0e Error - Leaque\Flvsystem\Filesystem:has0: Argument #1 (Slocation) must be of type string, null aiven, called in /home/liminnvie Frror - Leaque\ Flvsvstem\ Filesvstem«hach- Araument #1 (Slocation) must be of tvne strina. null aiven called in /home /iiminnvSO WaStack TraceShow 11 more framesIn AppIn AppIn AnnShow 2 more framesIn AppIn Aopin AnnIn AppIn AopIn AopShow 14 more framesIn AppShow 17 more framesView Full Trace100% CFri 8 May 15:29:27A Ask Seer9eAdd a comment8 Assianedby Lukas Kovalik to themselves.2 months agoMarked as Ongoing6 months aadautomatically by SentryFirst Seer6 months agoMarked as hich priorityy PeonleLK participatingSSIK MPIN viewedCimilar IecnodViowMeraed Issues...
|
Slack
|
Vasil Vasilev (DM) - Jiminny Inc - 5 new items - S Vasil Vasilev (DM) - Jiminny Inc - 5 new items - Slack...
|
NULL
|
9231
|
|
9230
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
More actions
Vasil Vasilev
Nikolay Ivanov
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
James Graham
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Canvas
Canvas
Files
Files
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
CircleCI
APP
Yesterday at 9:14:34 AM
9:14 AM
2 PRs with vulnerability fixes are ready for review
Please take a look at the code and confirm that everything works properly on your local machine or on a planet environment
Pull requests
(jiminny/app)
•
#12049 fix(security): composer dependency updates – 2026-05-07
#12049 fix(security): composer dependency updates – 2026-05-07
(
secfix/composer-20260507
)
•
#12048 fix(security): npm dependency updates – 2026-05-07
#12048 fix(security): npm dependency updates – 2026-05-07
(
secfix/npm-20260507
)
View workflow run
View workflow run
Stefka Stoyanova
Yesterday at 9:30:00 AM
9:30 AM
Team, does anyone use Jiminny (Development) Twilio account? It has only 35 obsolete phone numbers which I plan to delete, unless someone's using them?
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
Nikolay Yankov
Today at 2:38:37 PM
2:38 PM
My 5 hour limit in Claude just reset, I asked it 2 simple questions and 20% are already used.. It looks like it depends what time of the day you ask. It was not like that in the morning. After 12:00 - 13:00 it started to consume a lot more. Is it the same on your side? Or maybe I have to start a new session?
image.png
Toggle file
image.png
Download image.png
Share file: image.png
View canvas details
More actions
5 replies
Last reply today at 3:03 PM
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
Nikolay Yankov
Today at 2:39:02 PM
2:39 PM
This is with Sonnet
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Channel engineering
SlackFileEditViewGoHistoryWindowHelp‹ $0lahlLAAPP (-zsh)883DOCKERDEV (docker)882APP (-zsh)-zshPHPruntime:8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!Loadedconfig default from".php-cs-fixer.dist.php"5663/5663100%• 84screenpipe*Fixed 0 of 5663 files in 42.875 seconds, 60.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in any container or image → docker debug docker_lamp_1Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (master) $ git pullremote: Enumerating objects: 15,done.remote: Counting objects: 100% (15/15), done.remote: Compressing objects: 100% (2/2), done.remote: Total 15 (delta 13), reused 15 (delta 13), pack-reused 0 (from 0)Unpacking objects: 100% (15/15), 1.28 KiB | 72.00 KiB/s, done.From github.com:jiminny/appc57e71e763..8743fea32e* [new branch]Already up to date.lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $JY-20606-desktop-app-recall-> origin/JY-20606-desktop-app-recallJY-20819-increase-download-transctip-rate-limit -> origin/JY-20819-increase-download-transctip-rate-limit•$5100% C8Fri 8 May 15:29:24T₴1|-zsh₴6APP...
|
Slack
|
engineering (Channel) - Jiminny Inc - 6 new items engineering (Channel) - Jiminny Inc - 6 new items - Slack...
|
NULL
|
9230
|
|
9229
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Vasil Vasilev
Nikolay Ivanov
Galya Dimitrova
Aneliya Angelova
,
Nikolay Yankov
,
Steliyan Georgiev
Stoyan Tanev
Stefka Stoyanova
Ves
Aneliya Angelova
James Graham
Lukas Kovalik
you
Toast
Jira Cloud
Messages
Messages
Canvas
Canvas
Files
Files
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
CircleCI
APP
Yesterday at 9:14:34 AM
9:14 AM
2 PRs with vulnerability fixes are ready for review
Please take a look at the code and confirm that everything works properly on your local machine or on a planet environment
Pull requests
(jiminny/app)
•
#12049 fix(security): composer dependency updates – 2026-05-07
#12049 fix(security): composer dependency updates – 2026-05-07
(
secfix/composer-20260507
)
•
#12048 fix(security): npm dependency updates – 2026-05-07
#12048 fix(security): npm dependency updates – 2026-05-07
(
secfix/npm-20260507
)
View workflow run
View workflow run
Stefka Stoyanova
Yesterday at 9:30:00 AM
9:30 AM
Team, does anyone use Jiminny (Development) Twilio account? It has only 35 obsolete phone numbers which I plan to delete, unless someone's using them?
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
Nikolay Yankov
Today at 2:38:37 PM
2:38 PM
My 5 hour limit in Claude just reset, I asked it 2 simple questions and 20% are already used.. It looks like it depends what time of the day you ask. It was not like that in the morning. After 12:00 - 13:00 it started to consume a lot more. Is it the same on your side? Or maybe I have to start a new session?
image.png
Toggle file
image.png
Download image.png
Share file: image.png
View canvas details
More actions
5 replies
Last reply today at 3:03 PM
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
Nikolay Yankov
Today at 2:39:02 PM
2:39 PM
This is with Sonnet
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Channel engineering
Activit)FilesLateMoreSlackJiminny ...i contusion-clinid# curiosity lab# engineering# general#jiminny-bg# platform-tickets# product launches# random# releases# sofia-office# support# thank-yous#t the people of iimi.^? Direct messagesVasi VasilevR. Nikolay Ivanov• Galva Dimitrova(3 Aneliya Angelova..8 Stovan Taney €R Stefka StoyanovaVesf Aneliya AngelovaJames Grahame Lukas Kovalik v... 0#: Apps8 ToastEi lim Cloud© PavloadBuilder.ohp(C) Profile, ohoC) @uervBuilder.ohoC) @uerv.andler.ohoC) @uerviterator nhol© QueryResults.php© Service.php(C) SvncRatchRedicServTM Traits© BaseClient.phpC BaseService.php© CachedCrmServiceDecc© CountryCodeResolver.pC) Crm ActivityDrovidorintoHistoryWindowHeld@ Describe what you are looking for# engineering8 24• Messages©CanvasC FilesMore20260567)YesterdayStefka Stoyanova 9:30 AMTeam. does anyone use JiminnyDevelopment Iwillo account. It has oniv35 obsolete phone numbers which I olan todelete, unless someone's usine them?Nikolav Yankov 2:38 PMMy 5 hour limit in Glaude just reset. laskedit 2 simole questions and 20% are alreadv.used.. It looks like it depends what time ofthe dav vot ask.Ir was not like that in themorning After 12.00 - 13.00 it started toconsume a lot more is it the same on vourside? Or mavhe I have to start a newseccion?image.png08 g 5 replies Last reply today at...Message #engineering+ AaMatchermentitlestintenface.RemoteEntitvLookun iinterface)VenifvTaskExistsIntenfaceuse ResolveComnanvNameRvEmailTraituse SyncCrmEntitiesTrait;use WniteCrmTnait.uco CuncCioldeTnait.uce Annontuni+vSvneTnai+.zusagesprivate const int ENGAGEMENT BODYprivate const string LOG DATE FORM.Platform Sprint 3 Q2 - Platform TeSevenShores\Hubspot\Except XService-Desk - Queues - Platformg Jy 20807 check various issues witFeed - iminny — SentryJY-20818 move ask jiminny reportu Userpilot | Ask Jiminny Report GeJY-20773 fix user pilot tracking of* Problem loading pageSearch the CRM - HubSpot docse liminni) New Tab) New TalAl Features | DatadogJy 20493 smart instant nudge prePipelines - jiminny/app— New Ta:Explore08MonitorsSetting:Issues / Re APP-1EEDID: e72ac6cc12 hours ago JSON• SevenShores Hubspot Exceptions BadRequestv GuzZleHtto Exception ClientExceptionClient error:POST https://api.hubapi.com/crm/v3/objects/contact/search @* resulted in a429 Too Many Requests* response:{"status":"error", "message":"You have reached your secondly limit.", "errorType":"RATE_LIMIT", "correlationId":"019e04ea-a (truncated...)aenericCrashed in non-app:/vendor/guzzlehttp/guzzle/src/Exception/RequestException.php:111 in GuzzleHttp\Exception\RequestException::create/aon/Services/Crm/Hubsnot/Padination/HubsootPadinationService.nho:162_in_JiminnvlServices|Crm|Hubsoot\ Pagination\HubsnotPadinationServicexexecuteSearchReauest/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php:51 in Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService::getPaginatedDataGeneratorlapo/Services/Crm//Hubspot/Client.oho:94 in Jiminny Services Crm Hubspot Client«aetPacinatedDatal/app/Services/Crm/Hubspot/Service.php:1212 in Jiminny|Services\Crm\Hubspot\Service::Jiminny\Services\Crm\Hubspot\(closure)Called from: /vendor/laravel/framework/src/llluminate/Cache/Renositorv.nhn:564inllluminate|Cache\Renositorv.rememberlann/Services/Crm/Hubsnot /Service nhn-1206 in Jiminnv|Services|Crm|Hubsnot|Service«matchRvName/app/Services/Crm/CachedCrmServiceDecorator.php:167 in Jiminny|Services\Crm\CachedCrmServiceDecorator::matchByName/app/Services/Crm/CrmActivityService.php:139 in Jiminny\Services\Crm\CrmActivityService:.updateParticipantsCrmData/app/Services/Crm//CrmActivityService.oho:81 in JiminnyServices Crm_CrmActivityService:updateCrmDatalapo/Jobs/Crm/MatchActivitvCrmData.oho:107inJiminnv|Jobs|Crm.MatchActivitvCrmData:Jiminnv|Jobs\Crm\closurelCalled from:/vendor/laravel/framework/src/llluminate/Database/Concerns/ManaaesTransactions.oho:35 in llluminate\ Database Connection:transactionlapo/Jobs/Crm/MatchActivitvCrmData.ohn:87inJiminnv|Jobs\Crm|MatchActivitvCrmData«handleCalled from: /vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php:36 in Illuminate\Container\BoundMethod:IIluminate\Container\(closure)Called from:/vendor/laravel/framework/src/llluminate/Queue/Worker.ohp:435 in Illuminate Queue Worker-runJobv Trace Preview0e Error - Leaque\Flvsystem\Filesystem:has0: Argument #1 (Slocation) must be of type string, null aiven, called in /home/liminnvie Frror - Leaque\ Flvsvstem\ Filesvstem«hach- Araument #1 (Slocation) must be of tvne strina. null aiven called in /home /iiminnvSO WaStack TraceShow 11 more framesIn AppIn AppIn AnnShow 2 more framesIn AppIn Aopin AnnIn AppIn AopIn AopShow 14 more framesIn AppShow 17 more framesView Full Trace100% CFri 8 May 15:29:23A Ask Seer9eAdd a comment8 Assianedby Lukas Kovalik to themselves.2 months agoMarked as Ongoing6 months aadautomatically by Sentryc First Seer6 months agoMarked as hich priorityy PeonleLK participatingSSIK MPIN viewedCimilar IecnodViowMeraed Issues...
|
Slack
|
engineering (Channel) - Jiminny Inc - 6 new items engineering (Channel) - Jiminny Inc - 6 new items - Slack...
|
NULL
|
9229
|
|
9228
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Close tab
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to main content
Skip to main content
Toggle organization menu
Issues
Issues
Explore
Explore
Dashboards
Dashboards
Monitors
Monitors
Settings
Settings
Try Business
What's New
Help
[EMAIL]
Issues
Expand
Feed
Feed
Errors & Outages
Errors & Outages
Breached Metrics
Breached Metrics
Warnings
Warnings
User Feedback
User Feedback
Autofix
Autofix
Recently Run
Recently Run
All Views
All Views
Configure
Alerts Moved
Alerts
Moved
Issues
Issues
View Project Details
APP-1EED
Ask Seer
Ask Seer
/
Give Feedback
SevenShores\Hubspot\Exceptions\BadRequest
View events
Events (total)
Users (90d)
Level: Error
Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT","correlationId":"019e04ea-a (truncated...)
17K
0
Ongoing
/app/Services/Crm/Hubspot/Pagination/HubspotPaginationService.php in Jiminny\Services\Crm\Hubspot\Pagination\HubspotPaginationService::executeSearchRequest
Resolve
Resolve
More resolve options
Archive
Archive
Archive options
Subscribe
Share
More Actions
Priority
Modify issue priority
High
Assignee
Modify issue assignee
Lukas Kovalik
All Envs
All Envs
90D
90D
Add a search term
Add a search term
Close sidebar
Toggle graph series - Events
Events
17K
Toggle graph series - Users
Users
0
release 68% 874599
release
68%...
|
Firefox
|
SevenShores\Hubspot\Exceptions\BadRequest: Client SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT — Work...
|
jiminny.sentry.io/issues/7007366572/events/e72ac6c jiminny.sentry.io/issues/7007366572/events/e72ac6ccaa9445e3ae04d55cb8c32844/?project=82419...
|
9228
|
|
9227
|
Platform Sprint 3 Q2 - Platform Team - Scrum Board Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Platform Sprint 3 Q2 - Platform Team - Scrum Board - Jira
Unnamed Group
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Close tab
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Jy 20807 check various issues with stages by nikolaybiaivanov · Pull Request #12041 · jiminny/app
Feed — jiminny — Sentry
Feed — jiminny — Sentry
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
JY-20818 move ask jiminny reports to its own datadog metric by LakyLak · Pull Request #12056 · jiminny/app
Userpilot | Ask Jiminny Report Generated
Userpilot | Ask Jiminny Report Generated
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
JY-20773 fix user pilot tracking ofr automated report generated by LakyLak · Pull Request #12024 · jiminny/app
Problem loading page
Problem loading page
Search the CRM - HubSpot docs
Search the CRM - HubSpot docs
Jiminny
Jiminny
New Tab
New Tab
New Tab
New Tab
AI Features | Datadog
AI Features | Datadog
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Jy 20493 smart instant nudge pre filtering by nikolaybiaivanov · Pull Request #12053 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to main content
Skip to main content
Toggle organization menu
Issues
Issues
Explore
Explore
Dashboards
Dashboards
Monitors
Monitors
Settings
Settings
Try Business
What's New
Help
[EMAIL]
Issues
Expand
Feed
Feed
Errors & Outages
Errors & Outages
Breached Metrics
Breached Metrics
Warnings...
|
Firefox
|
SevenShores\Hubspot\Exceptions\BadRequest: Client SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT — Work...
|
jiminny.sentry.io/issues/7007366572/events/e72ac6c jiminny.sentry.io/issues/7007366572/events/e72ac6ccaa9445e3ae04d55cb8c32844/?project=82419...
|
9227
|
|
9226
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case Fi...
|
PhpStorm
|
faVsco.js – Service.php
|
NULL
|
9226
|
|
9225
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
PhpStormvlewINavigarecodeLaravelWindowFV faVsco.js°9 master kProledeyC Service.pnp ~©).liminnyDebugCommand.ongT DeleteCrmEntityTrait.phpC)Teamownerservice.onpRematchActivityOnCrmObjectDetach.php(C) CheckAndRetrvRemotematch.ong© MatchActivityCrmData.php© CrmActivityService.php© TeamService.php(C) TranscodeParameterResolv© Client.php© UserService.php© Uuid.php>@ Traits>D UseCases>@ User>O Utils> C Validation>0Ophp [EMAIL]@ Jiminny.ohp© Plan.phpC) Serializer.oho© TeamScimDetails.phpbootstrao>Mbuild>@ configcontribM databasedocstront-end0 lang• node_modules library root• M nhnstanli nublic> Mrecourcocv Mroutesphp api.phppnp apl_vz.ongpnp console.onppnp customer_api.pnppnp emoeddea.onophp health.phpphp scim.phophp uprotectedweb.phpphp web.phgphp webhook.php>Mscriotsv u storadeM debuabanM frameworkv Mloasaitianoreel audio wav= cuctom loal© PaginationConfig.php<?phpnamespace Jiminny Services Crm Hubspot.› use ...782 OY84184284389E hubspot-journal-poll.log= laravel lod•dphostan-tuoe CrmFielddefinition arrausiname: string,Label: strina.description: string.cype: strina.fieldType: string,hidden• hool.showCurrencySymbol: bool,options: array<array(1o. stringpublic function getTasks@?string $objectType, string Sobjectid, ?string $opportunityId): arrayt...}* Try to find CRM Objects using email address* @return null/arrayiLeadinul]Account|null,Opportunity|null,Contact|null,FetchRelatedActivityInterface,SettinasinterfaceMatchermentitles tintenfaceRemotesntitvLookuniinterfaceVenifvTaskExistsIntenfaceuse ResolveComnanvNameRvEmail Trait.use SuncCrmEntitiesTnait.use WniteCrmTnait.uco CuncCioldeTnait.uce Annontuni+vSvneTnai+.private const int ENGAGEMENT BODY MAX LENGTH = 65536:private const string LOG DATE FORMAT = 'Y-m-d H:i:s':Fri 8 May 15:29:13AskJiminnyReportActivityServiceTestv© RateLimitException.php747 A48 V1 V 33 81ОрнонннаCON A= custom.log~4 SF jiminny@localhost]A HS_local jiminny@localhost]« console [PROD]#console leu« console [STAGING][2026-05-07 14:21:15] local.INF0: [Hubspot] DEBUG Getting headers {"neaders".1"Date":["Thu,07 May 2026 14:21:15 GMT"],"content-lype". appLicacion/son, charser=utr-or"Transter-encoding" •L"chunked"!,"conneccion". "Keep-alive","CF-Ray" : ["9f80deb8db60dc3a-SOF"],CF-Cache-Status": "DYNAMIC"IN"Strict-Transport-Security": ["max-age=31536000; includeSubDomains; preload"],"access-control-allow-credentials": "false")."server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfn:desc="9+80deb8ercodcsa-AD"""x-content-tvoe-ootions":"nosniff"."x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],LẢẢẢẢẢẢẢ,ểẢhẢh ẢẢẢẢảẢ ẢảảẢ8ẢẢВẢẢẢ KA07-May-26 14:51:15 GMT; domain=.hubapi.com; Http0nly; Secure; SameSite=None"],"Renont-To"•".1"endnoints"•!N"url\":\"https:V/Va.nel.cloudflare.comV/report\V/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZ1zoYdxI%2BIxVpHmsKn30%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjUR08srzKSvJgZ%2BQ6R7"anoun ". "cf-nel".\"max_age\":604800}"],"NFI"•T"S"success_fraction\":0.01,"report_to\":|"cf-nel\".\"max_age\":604800}"],"Server": ["cloudflare"]}} {"correlation_id": "95236535-ec98-4541-b92a-adfa73b69eab","trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}W Windsurf Teams 31:1 UTF-8 P 4 spaces ®...
|
PhpStorm
|
faVsco.js – custom.log
|
NULL
|
9225
|
|
9224
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case Fi...
|
PhpStorm
|
faVsco.js – custom.log
|
NULL
|
9224
|
|
9223
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case Fi...
|
PhpStorm
|
faVsco.js – custom.log
|
NULL
|
9223
|
|
9222
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case Fi...
|
PhpStorm
|
faVsco.js – custom.log
|
NULL
|
9222
|