From b19ce60e74c08e82b6a71f36628a5c2b5550efbb Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Sat, 4 Sep 2021 16:59:24 +0300 Subject: [PATCH] notification refactoring --- .../Classes/AssignmentNotificators/Email.php | 101 +++-- .../DefaultAssignmentNotificator.php | 12 +- application/Espo/Entities/Note.php | 35 +- application/Espo/Entities/Notification.php | 14 + .../Espo/Hooks/Common/Notifications.php | 167 +------- application/Espo/Hooks/Note/Mentions.php | 147 +------ application/Espo/Hooks/Note/Notifications.php | 332 +-------------- application/Espo/Services/Note.php | 2 +- application/Espo/Services/Notification.php | 165 +------ .../Espo/Tools/Notification/HookProcessor.php | 193 +++++++++ .../Tools/Notification/NoteHookProcessor.php | 403 ++++++++++++++++++ .../Notification/NoteMentionHookProcessor.php | 181 ++++++++ .../Espo/Tools/Notification/Service.php | 187 ++++++++ .../Espo/Tools/Stream/Jobs/AutoFollow.php | 2 +- 14 files changed, 1110 insertions(+), 831 deletions(-) create mode 100644 application/Espo/Tools/Notification/HookProcessor.php create mode 100644 application/Espo/Tools/Notification/NoteHookProcessor.php create mode 100644 application/Espo/Tools/Notification/NoteMentionHookProcessor.php create mode 100644 application/Espo/Tools/Notification/Service.php diff --git a/application/Espo/Classes/AssignmentNotificators/Email.php b/application/Espo/Classes/AssignmentNotificators/Email.php index ef68d7601a..7aa4817cb5 100644 --- a/application/Espo/Classes/AssignmentNotificators/Email.php +++ b/application/Espo/Classes/AssignmentNotificators/Email.php @@ -29,21 +29,20 @@ namespace Espo\Classes\AssignmentNotificators; -use Espo\{ - Services\Email as EmailService, - Services\Stream as StreamService, - Entities\User, - ORM\Entity, -}; +use Espo\Services\Email as EmailService; +use Espo\Services\Services\Stream as StreamService; -use Espo\Core\{ - Notification\AssignmentNotificator, - Notification\UserEnabledChecker, - Notification\NotificatorParams, - ORM\EntityManager, - ServiceFactory, - AclManager, -}; +use Espo\Core\Notification\AssignmentNotificator; +use Espo\Core\Notification\UserEnabledChecker; +use Espo\Core\Notification\NotificatorParams; +use Espo\Core\ServiceFactory; +use Espo\Core\AclManager; + +use Espo\ORM\EntityManager; +use Espo\ORM\Entity; + +use Espo\Entities\User; +use Espo\Entities\Notification; use DateTime; use Exception; @@ -101,12 +100,12 @@ class Email implements AssignmentNotificator return; } - $dt = null; - try { $dt = new DateTime($dateSent); } - catch (Exception $e) {} + catch (Exception $e) { + return; + } if (!$dt) { return; @@ -128,14 +127,14 @@ class Email implements AssignmentNotificator if ( !in_array($userId, $userIdList) && !in_array($userId, $previousUserIdList) && - $userId != $this->user->id + $userId !== $this->user->getId() ) { $userIdList[] = $userId; } } $data = [ - 'emailId' => $entity->id, + 'emailId' => $entity->getId(), 'emailName' => $entity->get('name'), ]; @@ -159,13 +158,13 @@ class Email implements AssignmentNotificator if ($person) { $data['personEntityType'] = $person->getEntityType(); $data['personEntityName'] = $person->get('name'); - $data['personEntityId'] = $person->id; + $data['personEntityId'] = $person->getId(); } } $userIdFrom = null; - if ($person && $person->getEntityType() == 'User') { + if ($person && $person->getEntityType() === 'User') { $userIdFrom = $person->id; } @@ -241,48 +240,48 @@ class Email implements AssignmentNotificator continue; } - if ( - $entity->get('status') == 'Archived' || - $params->getOption('isBeingImported') - ) { - if ($parent) { - if ($this->getStreamService()->checkIsFollowed($parent, $userId)) { - continue; - } - } + $isArchivedOrBeingImported = + $entity->get('status') === 'Archived' || + $params->getOption('isBeingImported'); - if ($account) { - if ($this->getStreamService()->checkIsFollowed($account, $userId)) { - continue; - } - } - } if ( - $this->entityManager - ->getRepository('Notification') - ->where([ - 'type' => 'EmailReceived', - 'userId' => $userId, - 'relatedId' => $entity->id, - 'relatedType' => 'Email', - ]) - ->select(['id']) - ->findOne() + $isArchivedOrBeingImported && + $parent && + $this->getStreamService()->checkIsFollowed($parent, $userId) ) { continue; } - $notification = $this->entityManager->getEntity('Notification'); + if ( + $isArchivedOrBeingImported && + $account && + $this->getStreamService()->checkIsFollowed($account, $userId) + ) { + continue; + } - $notification->set([ - 'type' => 'EmailReceived', + $existing = $this->entityManager + ->getRepository(Notification::ENTITY_TYPE) + ->where([ + 'type' => Notification::TYPE_EMAIL_RECEIVED, + 'userId' => $userId, + 'relatedId' => $entity->getId(), + 'relatedType' => 'Email', + ]) + ->select(['id']) + ->findOne(); + + if ($existing) { + continue; + } + + $this->entityManager->createEntity(Notification::ENTITY_TYPE, [ + 'type' => Notification::TYPE_EMAIL_RECEIVED, 'userId' => $userId, 'data' => $data, 'relatedId' => $entity->getId(), 'relatedType' => 'Email', ]); - - $this->entityManager->saveEntity($notification); } } diff --git a/application/Espo/Core/Notification/DefaultAssignmentNotificator.php b/application/Espo/Core/Notification/DefaultAssignmentNotificator.php index b49ca916a9..b1e2d48fd8 100644 --- a/application/Espo/Core/Notification/DefaultAssignmentNotificator.php +++ b/application/Espo/Core/Notification/DefaultAssignmentNotificator.php @@ -30,12 +30,10 @@ namespace Espo\Core\Notification; use Espo\ORM\Entity; +use Espo\ORM\EntityManager; use Espo\Entities\User; - -use Espo\Core\{ - ORM\EntityManager, -}; +use Espo\Entities\Notification; class DefaultAssignmentNotificator implements AssignmentNotificator { @@ -101,15 +99,15 @@ class DefaultAssignmentNotificator implements AssignmentNotificator } } else { - $isNotSelfAssignment = $assignedUserId !== $this->user->id; + $isNotSelfAssignment = $assignedUserId !== $this->user->getId(); } if (!$isNotSelfAssignment) { return; } - $this->entityManager->createEntity('Notification', [ - 'type' => 'Assign', + $this->entityManager->createEntity(Notification::ENTITY_TYPE, [ + 'type' => Notification::TYPE_ASSIGN, 'userId' => $assignedUserId, 'data' => [ 'entityType' => $entity->getEntityType(), diff --git a/application/Espo/Entities/Note.php b/application/Espo/Entities/Note.php index 8307de80c8..935f5067cb 100644 --- a/application/Espo/Entities/Note.php +++ b/application/Espo/Entities/Note.php @@ -82,6 +82,26 @@ class Note extends Entity return $this->get('parentId'); } + public function getSuperParentType(): ?string + { + return $this->get('superParentType'); + } + + public function getSuperParentId(): ?string + { + return $this->get('superParentId'); + } + + public function getRelatedType(): ?string + { + return $this->get('relatedType'); + } + + public function getRelatedId(): ?string + { + return $this->get('relatedId'); + } + public function getData(): stdClass { return $this->get('data') ?? (object) []; @@ -92,6 +112,11 @@ class Note extends Entity return (bool) $this->get('isInternal'); } + public function getPost(): ?string + { + return $this->get('post'); + } + public function setAclIsProcessed(): void { $this->aclIsProcessed = true; @@ -142,7 +167,7 @@ class Note extends Entity $this->set('attachmentsTypes', $types); } - public function addNotifiedUserId($userId) + public function addNotifiedUserId($userId): void { $userIdList = $this->get('notifiedUserIdList'); @@ -157,13 +182,9 @@ class Note extends Entity $this->set('notifiedUserIdList', $userIdList); } - public function isUserIdNotified($userId) + public function isUserIdNotified($userId): bool { - $userIdList = $this->get('notifiedUserIdList'); - - if (!is_array($userIdList)) { - $userIdList = []; - } + $userIdList = $this->get('notifiedUserIdList') ?? []; return in_array($userId, $userIdList); } diff --git a/application/Espo/Entities/Notification.php b/application/Espo/Entities/Notification.php index 37a3183f4c..4a34622668 100644 --- a/application/Espo/Entities/Notification.php +++ b/application/Espo/Entities/Notification.php @@ -32,4 +32,18 @@ namespace Espo\Entities; class Notification extends \Espo\Core\ORM\Entity { public const ENTITY_TYPE = 'Notification'; + + public const TYPE_ENTITY_REMOVED = 'EntityRemoved'; + + public const TYPE_ASSIGN = 'Assign'; + + public const TYPE_EMAIL_RECEIVED = 'EmailReceived'; + + public const TYPE_NOTE = 'Note'; + + public const TYPE_MENTION_IN_POST = 'MentionInPost'; + + public const TYPE_MESSAGE = 'Message'; + + public const TYPE_SYSTEM = 'System'; } diff --git a/application/Espo/Hooks/Common/Notifications.php b/application/Espo/Hooks/Common/Notifications.php index 9bb3ce806f..64a5059db1 100644 --- a/application/Espo/Hooks/Common/Notifications.php +++ b/application/Espo/Hooks/Common/Notifications.php @@ -29,183 +29,44 @@ namespace Espo\Hooks\Common; -use Espo\{ - ORM\Entity, - Services\Stream as StreamService, -}; - -use Espo\Core\{ - Utils\Metadata, - Utils\Config, - ORM\EntityManager, - ServiceFactory, - Notification\AssignmentNotificator, - Notification\AssignmentNotificatorFactory, - Notification\NotificatorParams, -}; - -use Espo\Entities\User; +use Espo\Tools\Notification\HookProcessor; +use Espo\ORM\Entity; class Notifications { public static $order = 10; - private $notifatorsHash = []; + private $processor; - private $streamService; - - private $hasStreamCache = []; - - private $metadata; - - private $config; - - private $entityManager; - - private $serviceFactory; - - private $notificatorFactory; - - private $user; - - public function __construct( - Metadata $metadata, - Config $config, - EntityManager $entityManager, - ServiceFactory $serviceFactory, - AssignmentNotificatorFactory $notificatorFactory, - User $user - ) { - $this->metadata = $metadata; - $this->config = $config; - $this->entityManager = $entityManager; - $this->serviceFactory = $serviceFactory; - $this->notificatorFactory = $notificatorFactory; - $this->user = $user; + public function __construct(HookProcessor $processor) + { + $this->processor = $processor; } - public function afterSave(Entity $entity, array $options = []): void + public function afterSave(Entity $entity, array $options): void { if (!empty($options['silent']) || !empty($options['noNotifications'])) { return; } - $entityType = $entity->getEntityType(); - - /** - * No need to process assignment notifications for entity types that have Stream enabled. - * Users are notified via Stream notifications. - */ - if ($this->checkHasStream($entityType) && !$entity->hasLinkMultipleField('assignedUsers')) { - return; - } - - $assignmentNotificationsEntityList = $this->config->get('assignmentNotificationsEntityList', []); - - if (!in_array($entityType, $assignmentNotificationsEntityList)) { - return; - } - - $notificator = $this->getNotificator($entityType); - - if (!$notificator instanceof AssignmentNotificator) { - // For backward compatiblity. - $notificator->process($entity, $options); - - return; - } - - $params = NotificatorParams::create()->withRawOptions($options); - - $notificator->process($entity, $params); + $this->processor->afterSave($entity, $options); } - public function beforeRemove(Entity $entity, array $options = []): void + public function beforeRemove(Entity $entity, array $options): void { if (!empty($options['silent']) || !empty($options['noNotifications'])) { return; } - $entityType = $entity->getEntityType(); - - if ($this->checkHasStream($entityType)) { - $followersData = $this->getStreamService()->getEntityFollowers($entity); - - foreach ($followersData['idList'] as $userId) { - if ($userId === $this->user->id) { - continue; - } - - $notification = $this->entityManager->getEntity('Notification'); - - $notification->set([ - 'userId' => $userId, - 'type' => 'EntityRemoved', - 'data' => [ - 'entityType' => $entity->getEntityType(), - 'entityId' => $entity->id, - 'entityName' => $entity->get('name'), - 'userId' => $this->user->id, - 'userName' => $this->user->get('name'), - ], - ]); - - $this->entityManager->saveEntity($notification); - } - } + $this->processor->beforeRemove($entity); } - public function afterRemove(Entity $entity): void + public function afterRemove(Entity $entity, array $options): void { - $query = $this->entityManager->getQueryBuilder() - ->delete() - ->from('Notification') - ->where([ - 'OR' => [ - [ - 'relatedId' => $entity->id, - 'relatedType' => $entity->getEntityType(), - ], - [ - 'relatedParentId' => $entity->id, - 'relatedParentType' => $entity->getEntityType(), - ], - ], - ]) - ->build(); - - $this->entityManager->getQueryExecutor()->execute($query); - } - - private function checkHasStream($entityType): bool - { - if (!array_key_exists($entityType, $this->hasStreamCache)) { - $this->hasStreamCache[$entityType] = (bool) $this->metadata->get("scopes.{$entityType}.stream"); + if (!empty($options['silent'])) { + return; } - return $this->hasStreamCache[$entityType]; - } - - /** - * @return AssignmentNotificator - */ - private function getNotificator(string $entityType): object - { - if (empty($this->notifatorsHash[$entityType])) { - $notificator = $this->notificatorFactory->create($entityType); - - $this->notifatorsHash[$entityType] = $notificator; - } - - return $this->notifatorsHash[$entityType]; - } - - private function getStreamService(): StreamService - { - if (empty($this->streamService)) { - $this->streamService = $this->serviceFactory->create('Stream'); - } - - return $this->streamService; + $this->processor->afterRemove($entity); } } diff --git a/application/Espo/Hooks/Note/Mentions.php b/application/Espo/Hooks/Note/Mentions.php index 2994eefc9a..d1c63e70e7 100644 --- a/application/Espo/Hooks/Note/Mentions.php +++ b/application/Espo/Hooks/Note/Mentions.php @@ -29,157 +29,26 @@ namespace Espo\Hooks\Note; +use Espo\Tools\Notification\NoteMentionHookProcessor; use Espo\ORM\Entity; -use Espo\Core\{ - ORM\EntityManager, - ServiceFactory, - AclManager, - Acl, -}; - -use Espo\Entities\User; - class Mentions { public static $order = 9; - protected $notificationService = null; + private $processor; - protected $entityManager; - - protected $serviceFactory; - - protected $user; - - protected $aclManager; - - protected $acl; - - public function __construct( - EntityManager $entityManager, - ServiceFactory $serviceFactory, - User $user, - AclManager $aclManager, - Acl $acl - ) { - $this->entityManager = $entityManager; - $this->serviceFactory = $serviceFactory; - $this->user = $user; - $this->aclManager = $aclManager; - $this->acl = $acl; + public function __construct(NoteMentionHookProcessor $processor) + { + $this->processor = $processor; } - protected function addMentionData($entity) + public function beforeSave(Entity $entity, array $options): void { - $post = $entity->get('post'); - - $mentionData = (object) []; - - $previousMentionList = []; - - if (!$entity->isNew()) { - $data = $entity->get('data'); - - if (!empty($data) && !empty($data->mentions)) { - $previousMentionList = array_keys(get_object_vars($data->mentions)); - } - } - - preg_match_all('/(@[\w@.-]+)/', $post, $matches); - - $mentionCount = 0; - - if (is_array($matches) && !empty($matches[0]) && is_array($matches[0])) { - $parent = null; - - if ($entity->get('parentId') && $entity->get('parentType')) { - $parent = $this->entityManager - ->getEntity($entity->get('parentType'), $entity->get('parentId')); - } - - foreach ($matches[0] as $item) { - $userName = substr($item, 1); - - $user = $this->entityManager - ->getRepository('User') - ->where(['userName' => $userName]) - ->findOne(); - - if ($user) { - if (!$this->acl->checkUserPermission($user, 'assignment')) { - continue; - } - - $m = [ - 'id' => $user->id, - 'name' => $user->get('name'), - 'userName' => $user->get('userName'), - '_scope' => $user->getEntityType(), - ]; - - $mentionData->$item = (object) $m; - $mentionCount++; - - if (!in_array($item, $previousMentionList)) { - if ($user->id == $this->user->id) { - continue; - } - - $this->notifyAboutMention($entity, $user, $parent); - - $entity->addNotifiedUserId($user->id); - } - } - } - } - - $data = $entity->get('data'); - - if (empty($data)) { - $data = (object) []; - } - - if ($mentionCount) { - $data->mentions = $mentionData; - } - else { - unset($data->mentions); - } - - $entity->set('data', $data); - } - - public function beforeSave(Entity $entity) - { - if ($entity->get('type') == 'Post') { - $post = $entity->get('post'); - - $this->addMentionData($entity); - } - } - - protected function notifyAboutMention(Entity $entity, User $user, Entity $parent = null) - { - if ($user->isPortal()) { + if (!empty($options['silent'])) { return; } - if ($parent) { - if (!$this->aclManager->check($user, $parent, 'stream')) { - return; - } - } - - $this->getNotificationService()->notifyAboutMentionInPost($user->id, $entity->id); - } - - protected function getNotificationService() - { - if (empty($this->notificationService)) { - $this->notificationService = $this->serviceFactory->create('Notification'); - } - - return $this->notificationService; + $this->processor->beforeSave($entity); } } diff --git a/application/Espo/Hooks/Note/Notifications.php b/application/Espo/Hooks/Note/Notifications.php index 0a5b9627ac..39e5acae6b 100644 --- a/application/Espo/Hooks/Note/Notifications.php +++ b/application/Espo/Hooks/Note/Notifications.php @@ -30,345 +30,25 @@ namespace Espo\Hooks\Note; use Espo\ORM\Entity; - -use Espo\Core\{ - ServiceFactory, - AclManager as InternalAclManager, - Utils\Metadata, - ORM\EntityManager, -}; - -use Espo\Entities\User; - -use StdClass; +use Espo\Tools\Notification\NoteHookProcessor; class Notifications { - protected $notificationService = null; - - protected $streamService = null; - public static $order = 14; - protected $metadata; + private $processor; - protected $entityManager; - - protected $serviceFactory; - - protected $user; - - protected $internalAclManager; - - public function __construct( - Metadata $metadata, - EntityManager $entityManager, - ServiceFactory $serviceFactory, - User $user, - InternalAclManager $internalAclManager - ) { - $this->metadata = $metadata; - $this->entityManager = $entityManager; - $this->serviceFactory = $serviceFactory; - $this->user = $user; - $this->internalAclManager = $internalAclManager; - } - - protected function getMentionedUserIdList($entity) + public function __construct(NoteHookProcessor $processor) { - $mentionedUserList = []; - $data = $entity->get('data'); - - if (($data instanceof StdClass) && ($data->mentions instanceof StdClass)) { - $mentions = get_object_vars($data->mentions); - - foreach ($mentions as $d) { - $mentionedUserList[] = $d->id; - } - } - - return $mentionedUserList; + $this->processor = $processor; } - protected function getSubscriberList(string $parentType, string $parentId, bool $isInternal = false) - { - return $this->getStreamService()->getSubscriberList($parentType, $parentId, $isInternal); - } - - public function afterSave(Entity $entity, array $options = []) + public function afterSave(Entity $entity, array $options): void { if (!$entity->isNew() && empty($options['forceProcessNotifications'])) { return; } - $parentType = $entity->get('parentType'); - $parentId = $entity->get('parentId'); - $superParentType = $entity->get('superParentType'); - $superParentId = $entity->get('superParentId'); - - $notifyUserIdList = []; - - if ($parentType && $parentId) { - $userList = $this->getSubscriberList($parentType, $parentId, $entity->get('isInternal')); - - $userIdMetList = []; - - foreach ($userList as $user) { - $userIdMetList[] = $user->id; - } - - if ($superParentType && $superParentId) { - $additionalUserList = $this - ->getSubscriberList($superParentType, $superParentId, $entity->get('isInternal')); - - foreach ($additionalUserList as $user) { - if ($user->isPortal()) { - continue; - } - - if (in_array($user->id, $userIdMetList)) { - continue; - } - - $userIdMetList[] = $user->id; - $userList[] = $user; - } - } - - if ($entity->get('relatedType')) { - $targetType = $entity->get('relatedType'); - } - else { - $targetType = $parentType; - } - - $skipAclCheck = false; - - if (!$entity->isAclProcessed()) { - $skipAclCheck = true; - } - else { - $teamIdList = $entity->getLinkMultipleIdList('teams'); - $userIdList = $entity->getLinkMultipleIdList('users'); - } - - foreach ($userList as $user) { - if ($skipAclCheck) { - $notifyUserIdList[] = $user->id; - - continue; - } - - if ($user->isAdmin()) { - $notifyUserIdList[] = $user->id; - - continue; - } - - if ($user->isPortal()) { - if ($entity->get('relatedType')) { - continue; - } - else { - $notifyUserIdList[] = $user->id; - } - - continue; - } - - $level = $this->internalAclManager->getLevel($user, $targetType, 'read'); - - if ($level === 'all') { - $notifyUserIdList[] = $user->id; - - continue; - } - else if ($level === 'team') { - if (in_array($user->id, $userIdList)) { - $notifyUserIdList[] = $user->id; - - continue; - } - - if (!empty($teamIdList)) { - $userTeamIdList = $user->getLinkMultipleIdList('teams'); - - foreach ($teamIdList as $teamId) { - if (in_array($teamId, $userTeamIdList)) { - $notifyUserIdList[] = $user->id; - - break; - } - } - } - - continue; - - } else if ($level === 'own') { - if (in_array($user->id, $userIdList)) { - $notifyUserIdList[] = $user->id; - - continue; - } - } - } - - } else { - $targetType = $entity->get('targetType'); - - if ($targetType === 'users') { - $targetUserIdList = $entity->get('usersIds'); - - if (is_array($targetUserIdList)) { - foreach ($targetUserIdList as $userId) { - if ($userId === $this->user->id) { - continue; - } - - if (in_array($userId, $notifyUserIdList)) { - continue; - } - - $notifyUserIdList[] = $userId; - } - } - } - else if ($targetType === 'teams') { - $targetTeamIdList = $entity->get('teamsIds'); - - if (is_array($targetTeamIdList)) { - foreach ($targetTeamIdList as $teamId) { - $team = $this->entityManager->getEntity('Team', $teamId); - - if (!$team) { - continue; - } - - $targetUserList = $this->entityManager - ->getRDBRepository('Team') - ->getRelation($team, 'users') - ->where([ - 'isActive' => true, - ]) - ->find(); - - foreach ($targetUserList as $user) { - if ($user->id === $this->user->id) { - continue; - } - - if (in_array($user->id, $notifyUserIdList)) { - continue; - } - - $notifyUserIdList[] = $user->id; - } - } - } - } - else if ($targetType === 'portals') { - $targetPortalIdList = $entity->get('portalsIds'); - - if (is_array($targetPortalIdList)) { - foreach ($targetPortalIdList as $portalId) { - $portal = $this->entityManager->getEntity('Portal', $portalId); - - if (!$portal) { - continue; - } - - $targetUserList = $this->entityManager - ->getRDBRepository('Portal') - ->getRelation($portal, 'users') - ->where([ - 'isActive' => true, - ]) - ->find(); - - foreach ($targetUserList as $user) { - if ($user->id === $this->user->id) { - continue; - } - - if (in_array($user->id, $notifyUserIdList)) { - continue; - } - - $notifyUserIdList[] = $user->id; - } - } - } - } - else if ($targetType === 'all') { - $targetUserList = $this->entityManager - ->getRepository('User') - ->where([ - 'isActive' => true, - 'type' => ['regular', 'admin'], - ]) - ->find(); - - foreach ($targetUserList as $user) { - if ($user->id === $this->user->id) { - continue; - } - - $notifyUserIdList[] = $user->id; - } - } - } - - $notifyUserIdList = array_unique($notifyUserIdList); - - foreach ($notifyUserIdList as $i => $userId) { - if ($entity->isUserIdNotified($userId)) { - unset($notifyUserIdList[$i]); - - continue; - } - - if (!$entity->isNew()) { - if ( - $this->entityManager - ->getRepository('Notification') - ->select(['id']) - ->where([ - 'type' => 'Note', - 'relatedType' => 'Note', - 'relatedId' => $entity->id, - 'userId' => $userId, - ]) - ->findOne() - ) { - unset($notifyUserIdList[$i]); - - continue; - } - } - } - - $notifyUserIdList = array_values($notifyUserIdList); - - if (!empty($notifyUserIdList)) { - $this->getNotificationService()->notifyAboutNote($notifyUserIdList, $entity); - } - } - - protected function getNotificationService() - { - if (empty($this->notificationService)) { - $this->notificationService = $this->serviceFactory->create('Notification'); - } - - return $this->notificationService; - } - - protected function getStreamService() - { - if (empty($this->streamService)) { - $this->streamService = $this->serviceFactory->create('Stream'); - } - - return $this->streamService; + $this->processor->afterSave($entity); } } diff --git a/application/Espo/Services/Note.php b/application/Espo/Services/Note.php index 9b3826758b..6d5ae6f9c7 100644 --- a/application/Espo/Services/Note.php +++ b/application/Espo/Services/Note.php @@ -208,7 +208,7 @@ class Note extends Record $portalIdList = $entity->getLinkMultipleIdList('portals'); $teamIdList = $entity->getLinkMultipleIdList('teams'); - $targetUserList = null; + $targetUserList = []; if ($targetType === NoteEntity::TARGET_USERS) { $targetUserList = $this->entityManager diff --git a/application/Espo/Services/Notification.php b/application/Espo/Services/Notification.php index f22b0b3663..134639d2ed 100644 --- a/application/Espo/Services/Notification.php +++ b/application/Espo/Services/Notification.php @@ -29,177 +29,50 @@ namespace Espo\Services; -use Espo\Core\Utils\Util; - use Espo\Core\{ Record\Collection as RecordCollection, Exceptions\Error, }; -use Espo\Entities\Note; use Espo\Entities\User; use Espo\Services\Stream\NoteAccessControl; -use Espo\Core\Di; - -class Notification extends \Espo\Services\Record implements - - Di\WebSocketSubmissionAware +class Notification extends \Espo\Services\Record { - use Di\WebSocketSubmissionSetter; - protected $actionHistoryDisabled = true; private $noteAccessControl = null; - public function notifyAboutMentionInPost(string $userId, string $noteId): void - { - $notification = $this->entityManager->getEntity('Notification'); - - $notification->set([ - 'type' => 'MentionInPost', - 'data' => ['noteId' => $noteId], - 'userId' => $userId, - 'relatedId' => $noteId, - 'relatedType' => 'Note', - ]); - - $this->entityManager->saveEntity($notification); - } - - public function notifyAboutNote(array $userIdList, Note $note): void - { - $data = ['noteId' => $note->id]; - - $related = null; - - if ($note->get('relatedType') == 'Email') { - $related = $this->entityManager - ->getRepository('Email') - ->select(['id', 'sentById', 'createdById']) - ->where(['id' => $note->get('relatedId')]) - ->findOne(); - } - - $now = date('Y-m-d H:i:s'); - - $collection = $this->entityManager - ->getCollectionFactory() - ->create(); - - $userList = $this->entityManager - ->getRepository('User') - ->select(['id', 'type']) - ->where([ - 'isActive' => true, - 'id' => $userIdList, - ]) - ->find(); - - foreach ($userList as $user) { - $userId = $user->id; - - if (!$this->checkUserNoteAccess($user, $note)) { - continue; - } - - if ($note->get('createdById') === $user->id) { - continue; - } - - if ($related && $related->getEntityType() == 'Email' && $related->get('sentById') == $user->id) { - continue; - } - - if ($related && $related->get('createdById') == $user->id) { - continue; - } - - $notification = $this->entityManager->getEntity('Notification'); - - $notification->set([ - 'id' => Util::generateId(), - 'data' => $data, - 'type' => 'Note', - 'userId' => $userId, - 'createdAt' => $now, - 'relatedId' => $note->id, - 'relatedType' => 'Note', - 'relatedParentId' => $note->get('parentId'), - 'relatedParentType' => $note->get('parentType'), - ]); - - $collection[] = $notification; - } - - if (empty($collection)) { - return; - } - - $this->entityManager->getMapper()->massInsert($collection); - - if ($this->getConfig()->get('useWebSocket')) { - foreach ($userIdList as $userId) { - $this->webSocketSubmission->submit('newNotification', $userId); - } - } - } - - public function checkUserNoteAccess(User $user, Note $note): bool - { - if ($user->isPortal()) { - if ($note->get('relatedType')) { - if ($note->get('relatedType') === 'Email' && $note->get('parentType') === 'Case') { - return true; - } - - return false; - } - - return true; - } - - if ($note->get('relatedType')) { - if (!$this->getAclManager()->checkScope($user, $note->get('relatedType'))) { - return false; - } - } - - if ($note->get('parentType')) { - if (!$this->getAclManager()->checkScope($user, $note->get('parentType'))) { - return false; - } - } - - return true; - } - public function getNotReadCount(string $userId): int { - $whereClause = array( + $whereClause = [ 'userId' => $userId, - 'read' => 0 - ); + 'read' => false, + ]; $ignoreScopeList = $this->getIgnoreScopeList(); - if (!empty($ignoreScopeList)) { - $where = []; - $where[] = array( - 'OR' => array( + + if (count($ignoreScopeList)) { + $whereClause[] = [ + 'OR' => [ 'relatedParentType' => null, - 'relatedParentType!=' => $ignoreScopeList - ) - ); - $whereClause[] = $where; + 'relatedParentType!=' => $ignoreScopeList, + ] + ]; } - return $this->entityManager->getRepository('Notification')->where($whereClause)->count(); + return $this->entityManager + ->getRDBRepository('Notification') + ->where($whereClause) + ->count(); } public function markAllRead(string $userId) { - $update = $this->entityManager->getQueryBuilder()->update() + $update = $this->entityManager + ->getQueryBuilder() + ->update() ->in('Notification') ->set(['read' => true]) ->where([ @@ -359,7 +232,7 @@ class Notification extends \Espo\Services\Record implements return new RecordCollection($collection, $count); } - protected function getIgnoreScopeList(): array + private function getIgnoreScopeList(): array { $ignoreScopeList = []; diff --git a/application/Espo/Tools/Notification/HookProcessor.php b/application/Espo/Tools/Notification/HookProcessor.php new file mode 100644 index 0000000000..36bdcd0769 --- /dev/null +++ b/application/Espo/Tools/Notification/HookProcessor.php @@ -0,0 +1,193 @@ +metadata = $metadata; + $this->config = $config; + $this->entityManager = $entityManager; + $this->streamService = $streamService; + $this->notificatorFactory = $notificatorFactory; + $this->user = $user; + } + + public function afterSave(Entity $entity, array $options): void + { + $entityType = $entity->getEntityType(); + + /** + * No need to process assignment notifications for entity types that have Stream enabled. + * Users are notified via Stream notifications. + */ + if ($this->checkHasStream($entityType) && !$entity->hasLinkMultipleField('assignedUsers')) { + return; + } + + $assignmentNotificationsEntityList = $this->config->get('assignmentNotificationsEntityList') ?? []; + + if (!in_array($entityType, $assignmentNotificationsEntityList)) { + return; + } + + $notificator = $this->getNotificator($entityType); + + if (!$notificator instanceof AssignmentNotificator) { + // For backward compatiblity. + $notificator->process($entity, $options); + + return; + } + + $params = NotificatorParams::create()->withRawOptions($options); + + $notificator->process($entity, $params); + } + + public function beforeRemove(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + if (!$this->checkHasStream($entityType)) { + return; + } + + $followersData = $this->streamService->getEntityFollowers($entity); + + $userIdList = $followersData['idList']; + + foreach ($userIdList as $userId) { + if ($userId === $this->user->getId()) { + continue; + } + + $this->entityManager->createEntity(Notification::ENTITY_TYPE, [ + 'userId' => $userId, + 'type' => Notification::TYPE_ENTITY_REMOVED, + 'data' => [ + 'entityType' => $entity->getEntityType(), + 'entityId' => $entity->getId(), + 'entityName' => $entity->get('name'), + 'userId' => $this->user->getId(), + 'userName' => $this->user->get('name'), + ], + ]); + } + } + + public function afterRemove(Entity $entity): void + { + $query = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(Notification::ENTITY_TYPE) + ->where([ + 'OR' => [ + [ + 'relatedId' => $entity->getId(), + 'relatedType' => $entity->getEntityType(), + ], + [ + 'relatedParentId' => $entity->getId(), + 'relatedParentType' => $entity->getEntityType(), + ], + ], + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + private function checkHasStream(string $entityType): bool + { + if (!array_key_exists($entityType, $this->hasStreamCache)) { + $this->hasStreamCache[$entityType] = + (bool) $this->metadata->get(['scopes', $entityType, 'stream']); + } + + return $this->hasStreamCache[$entityType]; + } + + /** + * @return AssignmentNotificator + */ + private function getNotificator(string $entityType): object + { + if (empty($this->notifatorsHash[$entityType])) { + $notificator = $this->notificatorFactory->create($entityType); + + $this->notifatorsHash[$entityType] = $notificator; + } + + return $this->notifatorsHash[$entityType]; + } +} diff --git a/application/Espo/Tools/Notification/NoteHookProcessor.php b/application/Espo/Tools/Notification/NoteHookProcessor.php new file mode 100644 index 0000000000..a323292287 --- /dev/null +++ b/application/Espo/Tools/Notification/NoteHookProcessor.php @@ -0,0 +1,403 @@ +streamService = $streamService; + $this->service = $service; + $this->metadata = $metadata; + $this->entityManager = $entityManager; + $this->user = $user; + $this->internalAclManager = $internalAclManager; + } + + public function afterSave(Note $note): void + { + if ($note->getParentType() && $note->getParentId()) { + $this->afterSaveParent($note); + + return; + } + + $this->afterSaveNoParent($note); + } + + private function afterSaveParent(Note $note): void + { + $parentType = $note->getParentType(); + $parentId = $note->getParentId(); + $superParentType = $note->getSuperParentType(); + $superParentId = $note->getSuperParentId(); + + $userList = $this->getSubscriberList($parentType, $parentId, $note->isInternal()); + + $userIdMetList = []; + + foreach ($userList as $user) { + $userIdMetList[] = $user->getId(); + } + + if ($superParentType && $superParentId) { + $additionalUserList = $this->getSubscriberList( + $superParentType, + $superParentId, + $note->isInternal() + ); + + foreach ($additionalUserList as $user) { + if ( + $user->isPortal() || + in_array($user->getId(), $userIdMetList) + ) { + continue; + } + + $userIdMetList[] = $user->getId(); + $userList[] = $user; + } + } + + $targetType = $note->getRelatedType() ? $note->getRelatedType() : $parentType; + + // This is correct. + $skipAclCheck = !$note->isAclProcessed(); + + if (!$skipAclCheck) { + $teamIdList = $note->getLinkMultipleIdList('teams'); + $userIdList = $note->getLinkMultipleIdList('users'); + } + + foreach ($userList as $user) { + if ($skipAclCheck) { + $notifyUserIdList[] = $user->getId(); + + continue; + } + + if ($user->isAdmin()) { + $notifyUserIdList[] = $user->getId(); + + continue; + } + + if ($user->isPortal() && $note->getRelatedType()) { + continue; + } + + if ($user->isPortal()) { + $notifyUserIdList[] = $user->getId(); + + continue; + } + + $level = $this->internalAclManager->getLevel($user, $targetType, Table::ACTION_READ); + + if (!$this->checkUserAccess($user, $level, $teamIdList, $userIdList)) { + continue; + } + + $notifyUserIdList[] = $user->getId(); + } + + $this->processNotify($note, array_unique($notifyUserIdList)); + } + + private function afterSaveNoParent(Note $note): void + { + $targetType = $note->getTargetType(); + + if ($targetType === Note::TARGET_USERS) { + $this->afterSaveTargetUsers($note); + + return; + } + + if ($targetType === Note::TARGET_TEAMS) { + $this->afterSaveTargetTeams($note); + + return; + } + + if ($targetType === Note::TARGET_PORTALS) { + $this->afterSaveTargetPortals($note); + + return; + } + + if ($targetType === Note::TARGET_ALL) { + $this->afterSaveTargetAll($note); + + return; + } + } + + private function processNotify(Note $note, array $userIdList): void + { + $filteredUserIdList = array_filter( + $userIdList, + function (string $userId) use ($note) { + if ($note->isUserIdNotified($userId)) { + return false; + } + + if ($note->isNew()) { + return true; + } + + $existing = $this->entityManager + ->getRDBRepository(Notification::ENTITY_TYPE) + ->select(['id']) + ->where([ + 'type' => Notification::TYPE_NOTE, + 'relatedType' => Note::ENTITY_TYPE, + 'relatedId' => $note->getId(), + 'userId' => $userId, + ]) + ->findOne(); + + if ($existing) { + return false; + } + + return true; + } + ); + + if (!count($filteredUserIdList)) { + return; + } + + $this->service->notifyAboutNote($filteredUserIdList, $note); + } + + private function afterSaveTargetUsers(Note $note): void + { + $targetUserIdList = $note->get('usersIds') ?? []; + + if (!count($targetUserIdList)) { + return; + } + + $notifyUserIdList = []; + + foreach ($targetUserIdList as $userId) { + if ($userId === $this->user->getId()) { + continue; + } + + $notifyUserIdList[] = $userId; + } + + $this->processNotify($note, array_unique($notifyUserIdList)); + } + + private function afterSaveTargetTeams(Note $note): void + { + $targetTeamIdList = $note->get('teamsIds') ?? []; + + if (!count($targetTeamIdList)) { + return; + } + + foreach ($targetTeamIdList as $teamId) { + $team = $this->entityManager->getEntity(Team::ENTITY_TYPE, $teamId); + + if (!$team) { + continue; + } + + $targetUserList = $this->entityManager + ->getRDBRepository(Team::ENTITY_TYPE) + ->getRelation($team, 'users') + ->where([ + 'isActive' => true, + ]) + ->select('id') + ->find(); + + foreach ($targetUserList as $user) { + if ($user->getId() === $this->user->getId()) { + continue; + } + + $notifyUserIdList[] = $user->getId(); + } + } + + $this->processNotify($note, array_unique($notifyUserIdList)); + } + + private function afterSaveTargetPortals(Note $note): void + { + $targetPortalIdList = $note->get('portalsIds') ?? []; + + if (!count($targetPortalIdList)) { + return; + } + + $notifyUserIdList = []; + + foreach ($targetPortalIdList as $portalId) { + $portal = $this->entityManager->getEntity(Portal::ENTITY_TYPE, $portalId); + + if (!$portal) { + continue; + } + + $targetUserList = $this->entityManager + ->getRDBRepository(Portal::ENTITY_TYPE) + ->getRelation($portal, 'users') + ->where([ + 'isActive' => true, + ]) + ->select(['id']) + ->find(); + + foreach ($targetUserList as $user) { + if ($user->getId() === $this->user->getId()) { + continue; + } + + $notifyUserIdList[] = $user->getId(); + } + } + + $this->processNotify($note, array_unique($notifyUserIdList)); + } + + private function afterSaveTargetAll(Note $note): void + { + $targetUserList = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->where([ + 'isActive' => true, + 'type' => ['regular', 'admin'], + ]) + ->select('id') + ->find(); + + $notifyUserIdList = []; + + foreach ($targetUserList as $user) { + if ($user->getId() === $this->user->getId()) { + continue; + } + + $notifyUserIdList[] = $user->getId(); + } + + $this->processNotify($note, $notifyUserIdList); + } + + private function checkUserAccess( + User $user, + string $level, + array $teamIdList, + array $userIdList + ): bool { + + if ($level === Table::LEVEL_ALL) { + return true; + } + + if ($level === Table::LEVEL_TEAM) { + if (in_array($user->getId(), $userIdList)) { + return true; + } + + if (!count($teamIdList)) { + return false; + } + + $userTeamIdList = $user->getLinkMultipleIdList('teams'); + + foreach ($teamIdList as $teamId) { + if (in_array($teamId, $userTeamIdList)) { + return true; + } + } + + return false; + } + + if ($level === Table::LEVEL_OWN) { + return in_array($user->getId(), $userIdList); + } + + return false; + } + + /** + * @return User[] + */ + private function getSubscriberList(string $parentType, string $parentId, bool $isInternal = false): Collection + { + return $this->streamService->getSubscriberList($parentType, $parentId, $isInternal); + } +} diff --git a/application/Espo/Tools/Notification/NoteMentionHookProcessor.php b/application/Espo/Tools/Notification/NoteMentionHookProcessor.php new file mode 100644 index 0000000000..77f37dc4f9 --- /dev/null +++ b/application/Espo/Tools/Notification/NoteMentionHookProcessor.php @@ -0,0 +1,181 @@ +service = $service; + $this->entityManager = $entityManager; + $this->user = $user; + $this->acl = $acl; + $this->aclManager = $aclManager; + } + + public function beforeSave(Note $note): void + { + if ($note->getType() !== Note::TYPE_POST) { + return; + } + + $this->process($note); + } + + private function process(Note $note): void + { + $post = $note->getPost() ?? ''; + + $mentionData = (object) []; + + $previousMentionList = []; + + if (!$note->isNew()) { + $previousMentionList = array_keys( + get_object_vars($note->getData()->mentions ?? (object) []) + ); + } + + $matches = null; + + preg_match_all('/(@[\w@.-]+)/', $post, $matches); + + $mentionCount = 0; + + if (is_array($matches) && !empty($matches[0]) && is_array($matches[0])) { + $mentionCount = $this->processMatches($matches[0], $note, $mentionData, $previousMentionList); + } + + $data = $note->getData(); + + if ($mentionCount) { + $data->mentions = $mentionData; + } + else { + unset($data->mentions); + } + + $note->set('data', $data); + } + + private function processMatches( + array $matchList, + Note $note, + stdClass $mentionData, + array $previousMentionList + ): int { + + $mentionCount = 0; + + $parent = $note->getParentId() && $note->getParentType() ? + $this->entityManager->getEntity( + $note->getParentType(), + $note->getParentId() + ) : + null; + + foreach ($matchList as $item) { + $userName = substr($item, 1); + + /** @var User $user */ + $user = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->where([ + 'userName' => $userName, + 'isActive' => true, + ]) + ->findOne(); + + if (!$user) { + continue; + } + + if (!$this->acl->checkUserPermission($user, 'assignment')) { + continue; + } + + $mentionData->$item = (object) [ + 'id' => $user->getId(), + 'name' => $user->get('name'), + 'userName' => $user->get('userName'), + '_scope' => $user->getEntityType(), + ]; + + $mentionCount++; + + if (in_array($item, $previousMentionList)) { + continue; + } + + if ($user->getId() === $this->user->getId()) { + continue; + } + + if ($user->isPortal()) { + continue; + } + + if ($parent && !$this->aclManager->checkEntityStream($user, $parent)) { + continue; + } + + $note->addNotifiedUserId($user->getId()); + + $this->service->notifyAboutMentionInPost($user->getId(), $note); + } + + return $mentionCount; + } +} diff --git a/application/Espo/Tools/Notification/Service.php b/application/Espo/Tools/Notification/Service.php new file mode 100644 index 0000000000..39883cf834 --- /dev/null +++ b/application/Espo/Tools/Notification/Service.php @@ -0,0 +1,187 @@ +entityManager = $entityManager; + $this->config = $config; + $this->aclManager = $aclManager; + $this->webSocketSubmission = $webSocketSubmission; + } + + public function notifyAboutMentionInPost(string $userId, Note $note): void + { + $this->entityManager->createEntity(Notification::ENTITY_TYPE, [ + 'type' => Notification::TYPE_MENTION_IN_POST, + 'data' => [ + 'noteId' => $note->getId(), + ], + 'userId' => $userId, + 'relatedId' => $note->getId(), + 'relatedType' => Note::ENTITY_TYPE, + ]); + } + + public function notifyAboutNote(array $userIdList, Note $note): void + { + $related = null; + + if ($note->getRelatedType() === Email::ENTITY_TYPE) { + $related = $this->entityManager + ->getRDBRepository(Email::ENTITY_TYPE) + ->select(['id', 'sentById', 'createdById']) + ->where(['id' => $note->getRelatedId()]) + ->findOne(); + } + + $now = date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + + $collection = $this->entityManager + ->getCollectionFactory() + ->create(); + + $userList = $this->entityManager + ->getRDBRepository('User') + ->select(['id', 'type']) + ->where([ + 'isActive' => true, + 'id' => $userIdList, + ]) + ->find(); + + foreach ($userList as $user) { + $userId = $user->getId(); + + if (!$this->checkUserNoteAccess($user, $note)) { + continue; + } + + if ($note->get('createdById') === $user->getId()) { + continue; + } + + if ( + $related && + $related->getEntityType() === Email::ENTITY_TYPE && + $related->get('sentById') === $user->getId() + ) { + continue; + } + + if ($related && $related->get('createdById') === $user->getId()) { + continue; + } + + $notification = $this->entityManager->getEntity(Notification::ENTITY_TYPE); + + $notification->set([ + 'id' => Util::generateId(), + 'data' => [ + 'noteId' => $note->getId(), + ], + 'type' => Notification::TYPE_NOTE, + 'userId' => $userId, + 'createdAt' => $now, + 'relatedId' => $note->getId(), + 'relatedType' => Note::ENTITY_TYPE, + 'relatedParentId' => $note->getParentId(), + 'relatedParentType' => $note->getParentType(), + ]); + + $collection[] = $notification; + } + + if (!count($collection)) { + return; + } + + $this->entityManager->getMapper()->massInsert($collection); + + if ($this->config->get('useWebSocket')) { + foreach ($userIdList as $userId) { + $this->webSocketSubmission->submit('newNotification', $userId); + } + } + } + + private function checkUserNoteAccess(User $user, Note $note): bool + { + if ($user->isPortal()) { + if ($note->getRelatedType()) { + /** @todo Revise. */ + return $note->getRelatedType() === Email::ENTITY_TYPE && $note->getParentType() === 'Case'; + } + + return true; + } + + if ($note->getRelatedType()) { + if (!$this->aclManager->checkScope($user, $note->getRelatedType())) { + return false; + } + } + + if ($note->getParentType()) { + if (!$this->aclManager->checkScope($user, $note->getParentType())) { + return false; + } + } + + return true; + } +} diff --git a/application/Espo/Tools/Stream/Jobs/AutoFollow.php b/application/Espo/Tools/Stream/Jobs/AutoFollow.php index d4b4d4b5a0..cfc9232344 100644 --- a/application/Espo/Tools/Stream/Jobs/AutoFollow.php +++ b/application/Espo/Tools/Stream/Jobs/AutoFollow.php @@ -38,7 +38,7 @@ use Espo\Core\Acl\Exceptions\NotImplemented as AclNotImplemented; use Espo\ORM\EntityManager; use Espo\Services\Stream as Service; -use Espo\Services\Notification as NotificationService; +use Espo\Tools\Notification\Service as NotificationService; /** * Handles auto-follow.