diff --git a/application/Espo/Classes/AssignmentNotificators/Email.php b/application/Espo/Classes/AssignmentNotificators/Email.php index 11945cf446..916772b2a6 100644 --- a/application/Espo/Classes/AssignmentNotificators/Email.php +++ b/application/Espo/Classes/AssignmentNotificators/Email.php @@ -29,7 +29,7 @@ namespace Espo\Classes\AssignmentNotificators; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\Core\Notification\AssignmentNotificator; use Espo\Core\Notification\AssignmentNotificator\Params; diff --git a/application/Espo/Controllers/Stream.php b/application/Espo/Controllers/Stream.php index 09ec284712..8dd7f87bae 100644 --- a/application/Espo/Controllers/Stream.php +++ b/application/Espo/Controllers/Stream.php @@ -36,7 +36,7 @@ use Espo\Core\{ Record\SearchParamsFetcher, }; -use Espo\Services\Stream as Service; +use Espo\Tools\Stream\Service as Service; use stdClass; @@ -44,9 +44,8 @@ class Stream { public static string $defaultAction = 'list'; - private $service; - - private $searchParamsFetcher; + private Service $service; + private SearchParamsFetcher $searchParamsFetcher; public function __construct( Service $service, diff --git a/application/Espo/Core/FieldProcessing/Stream/FollowersLoader.php b/application/Espo/Core/FieldProcessing/Stream/FollowersLoader.php index 31e43d29f3..aefc0b78ee 100644 --- a/application/Espo/Core/FieldProcessing/Stream/FollowersLoader.php +++ b/application/Espo/Core/FieldProcessing/Stream/FollowersLoader.php @@ -41,7 +41,7 @@ use Espo\Core\{ use Espo\Entities\User; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; /** * @implements LoaderInterface @@ -51,13 +51,9 @@ class FollowersLoader implements LoaderInterface private const FOLLOWERS_LIMIT = 6; private StreamService $streamService; - private Metadata $metadata; - private User $user; - private Acl $acl; - private Config $config; public function __construct( diff --git a/application/Espo/Core/Mail/Account/GroupAccount/Hooks/AfterFetch.php b/application/Espo/Core/Mail/Account/GroupAccount/Hooks/AfterFetch.php index d207e2e09c..9c96717867 100644 --- a/application/Espo/Core/Mail/Account/GroupAccount/Hooks/AfterFetch.php +++ b/application/Espo/Core/Mail/Account/GroupAccount/Hooks/AfterFetch.php @@ -47,7 +47,7 @@ use Espo\Core\Utils\Crypt; use Espo\Core\InjectableFactory; use Espo\Core\Utils\Log; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\Services\EmailTemplate as EmailTemplateService; use Espo\Entities\InboundEmail; diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/Hooks/AfterFetch.php b/application/Espo/Core/Mail/Account/PersonalAccount/Hooks/AfterFetch.php index c97192fa37..bd88c950e8 100644 --- a/application/Espo/Core/Mail/Account/PersonalAccount/Hooks/AfterFetch.php +++ b/application/Espo/Core/Mail/Account/PersonalAccount/Hooks/AfterFetch.php @@ -33,7 +33,7 @@ use Espo\Core\Mail\Account\Account; use Espo\Core\Mail\Account\Hook\BeforeFetchResult; use Espo\Core\Mail\Account\Hook\AfterFetch as AfterFetchInterface; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\Entities\Email; use Espo\ORM\EntityManager; @@ -41,7 +41,6 @@ use Espo\ORM\EntityManager; class AfterFetch implements AfterFetchInterface { private EntityManager $entityManager; - private StreamService $streamService; public function __construct( diff --git a/application/Espo/Core/Mail/Importer.php b/application/Espo/Core/Mail/Importer.php index ba850becf1..0f8407ba57 100644 --- a/application/Espo/Core/Mail/Importer.php +++ b/application/Espo/Core/Mail/Importer.php @@ -29,10 +29,11 @@ namespace Espo\Core\Mail; +use Espo\Core\Job\Job\Data as JobData; +use Espo\Core\Job\JobSchedulerFactory; use Espo\Core\Mail\Importer\DuplicateFinder; use Espo\Entities\Email; use Espo\Entities\EmailFilter; -use Espo\Entities\Job; use Espo\Modules\Crm\Entities\Account; use Espo\Modules\Crm\Entities\Contact; use Espo\Repositories\Email as EmailRepository; @@ -56,6 +57,7 @@ use Espo\Modules\Crm\Entities\Lead; use DateTime; use DateTimeZone; +use Espo\Tools\Stream\Jobs\ProcessNoteAcl; use Exception; /** @@ -64,6 +66,7 @@ use Exception; class Importer { private const SUBJECT_MAX_LENGTH = 255; + private const PROCESS_ACL_DELAY_PERIOD = '5 seconds'; private EntityManager $entityManager; private Config $config; @@ -72,6 +75,7 @@ class Importer private ParserFactory $parserFactory; private LinkMultipleSaver $linkMultipleSaver; private DuplicateFinder $duplicateFinder; + private JobSchedulerFactory $jobSchedulerFactory; public function __construct( EntityManager $entityManager, @@ -79,13 +83,15 @@ class Importer AssignmentNotificatorFactory $notificatorFactory, ParserFactory $parserFactory, LinkMultipleSaver $linkMultipleSaver, - DuplicateFinder $duplicateFinder + DuplicateFinder $duplicateFinder, + JobSchedulerFactory $jobSchedulerFactory ) { $this->entityManager = $entityManager; $this->config = $config; $this->parserFactory = $parserFactory; $this->linkMultipleSaver = $linkMultipleSaver; $this->duplicateFinder = $duplicateFinder; + $this->jobSchedulerFactory = $jobSchedulerFactory; $this->notificator = $notificatorFactory->create(Email::ENTITY_TYPE); $this->filtersMatcher = new FiltersMatcher(); @@ -774,25 +780,20 @@ class Importer // To grant access to the user who received the email. $dt = new DateTime(); + $dt->modify('+' . self::PROCESS_ACL_DELAY_PERIOD); - $dt->modify('+5 seconds'); - - $executeAt = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); - - $job = $this->entityManager->getNewEntity(Job::ENTITY_TYPE); - - $job->set([ - 'serviceName' => 'Stream', - 'methodName' => 'processNoteAclJob', - 'data' => [ - 'targetType' => Email::ENTITY_TYPE, - 'targetId' => $duplicate->getId(), - ], - 'executeAt' => $executeAt, - 'queue' => QueueName::Q1, - ]); - - $this->entityManager->saveEntity($job); + $this->jobSchedulerFactory + ->create() + ->setClassName(ProcessNoteAcl::class) + ->setData( + JobData + ::create() + ->withTargetId($duplicate->getId()) + ->withTargetType(Email::ENTITY_TYPE) + ) + ->setQueue(QueueName::Q1) + ->setTime($dt) + ->schedule(); } } diff --git a/application/Espo/Core/MassAction/Actions/MassFollow.php b/application/Espo/Core/MassAction/Actions/MassFollow.php index fd3e86772f..2c18ea7cc2 100644 --- a/application/Espo/Core/MassAction/Actions/MassFollow.php +++ b/application/Espo/Core/MassAction/Actions/MassFollow.php @@ -29,7 +29,7 @@ namespace Espo\Core\MassAction\Actions; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\Core\{ MassAction\QueryBuilder, @@ -48,15 +48,11 @@ use Espo\{ class MassFollow implements MassAction { - private $queryBuilder; - - private $acl; - - private $streamService; - - private $entityManager; - - private $user; + private QueryBuilder $queryBuilder; + private Acl $acl; + private StreamService $streamService; + private EntityManager $entityManager; + private User $user; public function __construct( QueryBuilder $queryBuilder, diff --git a/application/Espo/Core/MassAction/Actions/MassUnfollow.php b/application/Espo/Core/MassAction/Actions/MassUnfollow.php index 6d032686c7..570be8208d 100644 --- a/application/Espo/Core/MassAction/Actions/MassUnfollow.php +++ b/application/Espo/Core/MassAction/Actions/MassUnfollow.php @@ -29,7 +29,7 @@ namespace Espo\Core\MassAction\Actions; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\Core\{ MassAction\QueryBuilder, @@ -47,13 +47,10 @@ use Espo\{ class MassUnfollow implements MassAction { - private $queryBuilder; - - private $streamService; - - private $entityManager; - - private $user; + private QueryBuilder $queryBuilder; + private StreamService $streamService; + private EntityManager $entityManager; + private User $user; public function __construct( QueryBuilder $queryBuilder, diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index a208657fb6..6c365f5095 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -51,7 +51,7 @@ use Espo\ORM\Query\Part\WhereClause; use Espo\Entities\User; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\Core\{ Acl, diff --git a/application/Espo/Hooks/Common/StreamNotesAcl.php b/application/Espo/Hooks/Common/StreamNotesAcl.php index 55e680da11..a8d48d1442 100644 --- a/application/Espo/Hooks/Common/StreamNotesAcl.php +++ b/application/Espo/Hooks/Common/StreamNotesAcl.php @@ -31,7 +31,7 @@ namespace Espo\Hooks\Common; use Espo\ORM\Entity; -use Espo\Services\Stream as Service; +use Espo\Tools\Stream\Service as Service; /** * Notes having `related` or `superParent` are subjects to access control diff --git a/application/Espo/Modules/Crm/Repositories/CaseObj.php b/application/Espo/Modules/Crm/Repositories/CaseObj.php index dd614b986d..fe04e56a86 100644 --- a/application/Espo/Modules/Crm/Repositories/CaseObj.php +++ b/application/Espo/Modules/Crm/Repositories/CaseObj.php @@ -30,7 +30,7 @@ namespace Espo\Modules\Crm\Repositories; use Espo\ORM\Entity; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\Core\Di; /** diff --git a/application/Espo/Services/Stream.php b/application/Espo/Services/Stream.php index 44fe39cfd7..e4990bafd2 100644 --- a/application/Espo/Services/Stream.php +++ b/application/Espo/Services/Stream.php @@ -29,2376 +29,7 @@ namespace Espo\Services; -use Espo\Core\Exceptions\Forbidden; -use Espo\Core\Exceptions\NotFound; -use Espo\Core\Exceptions\Error; -use Espo\Core\Record\ServiceContainer as RecordServiceContainer; -use Espo\Core\ORM\Entity as CoreEntity; - -use Espo\Tools\Stream\NoteAccessControl; - -use Espo\Repositories\EmailAddress as EmailAddressRepository; - -use Espo\ORM\{ - Entity, - EntityCollection, - Collection, -}; - -use Espo\{ - Entities\User, - Entities\Note as NoteEntity, - Entities\Email, - Entities\EmailAddress, -}; - -use Espo\Core\{ - ORM\EntityManager, - Utils\Config, - Utils\Metadata, - Acl, - AclManager, - Acl\Table, - Acl\Exceptions\NotImplemented as AclNotImplemented, - Utils\FieldUtil, - Record\Collection as RecordCollection, - Select\SelectBuilderFactory, - Select\SearchParams, - Utils\Acl\UserAclManagerProvider, -}; - -use stdClass; -use DateTime; -use LogicException; - -class Stream -{ - /** @var ?array */ - private $statusStyles = null; - /** @var ?array */ - private $statusFields = null; - /** @var string[] */ - private $successDefaultStyleList = [ - 'Held', - 'Closed Won', - 'Closed', - 'Completed', - 'Complete', - 'Sold', - ]; - /** @var string[] */ - private $dangerDefaultStyleList = [ - 'Not Held', - 'Closed Lost', - 'Dead', - ]; - - /** - * - * @var array< - * string, - * array< - * string, - * array{ - * actualList: string[], - * notActualList: string[], - * fieldType: string, - * } - * > - * > - */ - private $auditedFieldsCache = []; - - /** - * When a record is re-assigned, ACL will be recalculated for related notes - * created within the period. - */ - private const NOTE_ACL_PERIOD = '3 days'; - - private const NOTE_ACL_LIMIT = 50; - - private const SYSTEM_USER_ID = 'system'; - - /** - * Not used currently. - */ - private const NOTE_NOTIFICATION_PERIOD = '1 hour'; - - private EntityManager $entityManager; - private Config $config; - private User $user; - private Metadata $metadata; - private Acl $acl; - private AclManager $aclManager; - private FieldUtil $fieldUtil; - private SelectBuilderFactory $selectBuilderFactory; - private UserAclManagerProvider $userAclManagerProvider; - private NoteAccessControl $noteAccessControl; - private RecordServiceContainer $recordServiceContainer; - - public function __construct( - EntityManager $entityManager, - Config $config, - User $user, - Metadata $metadata, - Acl $acl, - AclManager $aclManager, - FieldUtil $fieldUtil, - SelectBuilderFactory $selectBuilderFactory, - UserAclManagerProvider $userAclManagerProvider, - NoteAccessControl $noteAccessControl, - RecordServiceContainer $recordServiceContainer - ) { - $this->entityManager = $entityManager; - $this->config = $config; - $this->user = $user; - $this->metadata = $metadata; - $this->acl = $acl; - $this->aclManager = $aclManager; - $this->fieldUtil = $fieldUtil; - $this->selectBuilderFactory = $selectBuilderFactory; - $this->userAclManagerProvider = $userAclManagerProvider; - $this->noteAccessControl = $noteAccessControl; - $this->recordServiceContainer = $recordServiceContainer; - } - - /** - * @return array - */ - private function getStatusStyles(): array - { - if (empty($this->statusStyles)) { - $this->statusStyles = $this->metadata->get('entityDefs.Note.statusStyles', []); - } - - return $this->statusStyles; - } - - /** - * @return array - */ - private function getStatusFields(): array - { - if (is_null($this->statusFields)) { - $this->statusFields = []; - - /** @var array> $scopes */ - $scopes = $this->metadata->get('scopes', []); - - foreach ($scopes as $scope => $data) { - /** @var ?string $statusField */ - $statusField = $data['statusField'] ?? null; - - if (!$statusField) { - continue; - } - - $this->statusFields[$scope] = $statusField; - } - } - - return $this->statusFields; - } - - public function checkIsFollowed(Entity $entity, ?string $userId = null): bool - { - if (!$userId) { - $userId = $this->user->getId(); - } - - $isFollowed = (bool) $this->entityManager - ->getRDBRepository('Subscription') - ->select(['id']) - ->where([ - 'userId' => $userId, - 'entityType' => $entity->getEntityType(), - 'entityId' => $entity->getId(), - ]) - ->findOne(); - - return $isFollowed; - } - - /** - * @param string[] $sourceUserIdList - */ - public function followEntityMass(Entity $entity, array $sourceUserIdList, bool $skipAclCheck = false): void - { - if (!$this->metadata->get(['scopes', $entity->getEntityType(), 'stream'])) { - return; - } - - $userIdList = []; - - foreach ($sourceUserIdList as $id) { - if ($id === self::SYSTEM_USER_ID) { - continue; - } - - $userIdList[] = $id; - } - - $userIdList = array_unique($userIdList); - - if (!$skipAclCheck) { - foreach ($userIdList as $i => $userId) { - $user = $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) - ->select(['id', 'type', 'isActive']) - ->where([ - 'id' => $userId, - 'isActive' => true, - ]) - ->findOne(); - - if (!$user) { - unset($userIdList[$i]); - continue; - } - - try { - $hasAccess = $this->aclManager->checkEntityStream($user, $entity); - } - catch (AclNotImplemented $e) { - $hasAccess = false; - } - - if (!$hasAccess) { - unset($userIdList[$i]); - } - } - - $userIdList = array_values($userIdList); - } - - if (empty($userIdList)) { - return; - } - - $delete = $this->entityManager->getQueryBuilder() - ->delete() - ->from('Subscription') - ->where([ - 'userId' => $userIdList, - 'entityId' => $entity->getId(), - 'entityType' => $entity->getEntityType(), - ]) - ->build(); - - $this->entityManager->getQueryExecutor()->execute($delete); - - $collection = new EntityCollection(); - - foreach ($userIdList as $userId) { - $subscription = $this->entityManager->getNewEntity('Subscription'); - - $subscription->set([ - 'userId' => $userId, - 'entityId' => $entity->getId(), - 'entityType' => $entity->getEntityType(), - ]); - - $collection[] = $subscription; - } - - $this->entityManager->getMapper()->massInsert($collection); - } - - public function followEntity(Entity $entity, string $userId, bool $skipAclCheck = false): bool - { - if ($userId === self::SYSTEM_USER_ID) { - return false; - } - - if (!$this->metadata->get(['scopes', $entity->getEntityType(), 'stream'])) { - return false; - } - - if (!$skipAclCheck) { - $user = $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) - ->select(['id', 'type', 'isActive']) - ->where([ - 'id' => $userId, - 'isActive' => true, - ]) - ->findOne(); - - if (!$user) { - return false; - } - - $aclManager = $this->getUserAclManager($user); - - if (!$aclManager) { - return false; - } - - if (!$aclManager->check($user, $entity, Table::ACTION_STREAM)) { - return false; - } - } - - if ($this->checkIsFollowed($entity, $userId)) { - return true; - } - - $this->entityManager->createEntity('Subscription', [ - 'entityId' => $entity->getId(), - 'entityType' => $entity->getEntityType(), - 'userId' => $userId, - ]); - - return true; - } - - public function unfollowEntity(Entity $entity, string $userId): bool - { - if (!$this->metadata->get('scopes.' . $entity->getEntityType() . '.stream')) { - return false; - } - - $delete = $this->entityManager->getQueryBuilder() - ->delete() - ->from('Subscription') - ->where([ - 'userId' => $userId, - 'entityId' => $entity->getId(), - 'entityType' => $entity->getEntityType(), - ]) - ->build(); - - $this->entityManager->getQueryExecutor()->execute($delete); - - return true; - } - - public function unfollowAllUsersFromEntity(Entity $entity): void - { - if (!$entity->hasId()) { - return; - } - - $delete = $this->entityManager->getQueryBuilder() - ->delete() - ->from('Subscription') - ->where([ - 'entityId' => $entity->getId(), - 'entityType' => $entity->getEntityType(), - ]) - ->build(); - - $this->entityManager->getQueryExecutor()->execute($delete); - } - - /** - * @param array{ - * offset?: int|null, - * maxSize: int|null, - * skipOwn?: bool, - * where?: ?array, - * after?: ?string, - * filter?: ?string, - * } $params - * @throws NotFound - * @throws Forbidden - */ - public function findUserStream(string $userId, array $params): stdClass - { - $offset = intval($params['offset'] ?? 0); - $maxSize = intval($params['maxSize']); - - $sqLimit = $offset + $maxSize + 1; - - if ($userId === $this->user->getId()) { - $user = $this->user; - } - else { - /** @var ?User $user */ - $user = $this->entityManager->getEntity(User::ENTITY_TYPE, $userId); - - if (!$user) { - throw new NotFound(); - } - - if (!$this->acl->checkUserPermission($user, 'user')) { - throw new Forbidden(); - } - } - - $skipOwn = $params['skipOwn'] ?? false; - - $teamIdList = $user->getTeamIdList(); - - $select = [ - 'id', - 'number', - 'type', - 'post', - 'data', - 'parentType', - 'parentId', - 'relatedType', - 'relatedId', - 'targetType', - 'createdAt', - 'createdById', - 'createdByName', - 'isGlobal', - 'isInternal', - 'createdByGender', - ]; - - $onlyTeamEntityTypeList = $this->getOnlyTeamEntityTypeList($user); - $onlyOwnEntityTypeList = $this->getOnlyOwnEntityTypeList($user); - - $additionalQuery = null; - - if (!empty($params['where'])) { - $searchParams = SearchParams::fromRaw([ - 'where' => $params['where'], - ]); - - $additionalQuery = $this->selectBuilderFactory - ->create() - ->from(NoteEntity::ENTITY_TYPE) - ->withComplexExpressionsForbidden() - ->withWherePermissionCheck() - ->withSearchParams($searchParams) - ->buildQueryBuilder() - ->order([]) - ->build(); - } - - $queryList = []; - - $baseBuilder = $this->entityManager->getQueryBuilder()->select(); - - if ($additionalQuery) { - $baseBuilder->clone($additionalQuery); - } - else { - $baseBuilder->from(NoteEntity::ENTITY_TYPE); - } - - $baseBuilder - ->select($select) - ->order('number', 'DESC') - ->limit(0, $sqLimit) - ->where( - $this->getUserStreamWhereClause($params, $user) - ); - - $subscriptionBuilder = clone $baseBuilder; - - $subscriptionIgnoreWhereClause = $this->getUserStreamSubscriptionIgnoreWhereClause($user); - - $subscriptionBuilder - ->leftJoin('createdBy') - ->join( - 'Subscription', - 'subscription', - [ - 'entityType:' => 'parentType', - 'entityId:' => 'parentId', - 'subscription.userId' => $user->getId(), - ] - ) - ->where($subscriptionIgnoreWhereClause); - - if ($user->isPortal()) { - $subscriptionBuilder->where([ - 'isInternal' => false, - ]); - - $notAllEntityTypeList = $this->getNotAllEntityTypeList($user); - - $orGroup = [ - [ - 'relatedId' => null, - ], - [ - 'relatedId!=' => null, - 'relatedType!=' => $notAllEntityTypeList, - ], - ]; - - $aclManager = $this->getUserAclManager($user); - - if ($aclManager && $aclManager->check($user, Email::ENTITY_TYPE, Table::ACTION_READ)) { - $orGroup[] = [ - 'relatedId!=' => null, - 'relatedType' => Email::ENTITY_TYPE, - 'noteUser.userId' => $user->getId(), - ]; - - $subscriptionBuilder->leftJoin( - 'noteUser', - 'noteUser', [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - 'note.relatedType' => Email::ENTITY_TYPE, - ] - ); - } - - $subscriptionBuilder->where([ - 'OR' => $orGroup, - ]); - - $queryList[] = $subscriptionBuilder->build(); - } - - if (!$user->isPortal()) { - $subscriptionRestBuilder = clone $subscriptionBuilder; - - $subscriptionRestBuilder->where([ - 'OR' => [ - [ - 'relatedId!=' => null, - 'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), - ], - [ - 'relatedId=' => null, - ], - ], - ]); - - $queryList[] = $subscriptionRestBuilder->build(); - - if (count($onlyTeamEntityTypeList)) { - $subscriptionTeamBuilder = clone $subscriptionBuilder; - - $subscriptionTeamBuilder - ->distinct() - ->leftJoin( - 'noteTeam', - 'noteTeam', - [ - 'noteTeam.noteId=:' => 'id', - 'noteTeam.deleted' => false, - ] - ) - ->leftJoin( - 'noteUser', - 'noteUser', - [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - ] - ) - ->where([ - [ - 'relatedId!=' => null, - 'relatedType=' => $onlyTeamEntityTypeList, - ], - [ - 'OR' => [ - 'noteTeam.teamId' => $teamIdList, - 'noteUser.userId' => $user->getId(), - ], - ], - ]); - - $queryList[] = $subscriptionTeamBuilder->build(); - } - - if (count($onlyOwnEntityTypeList)) { - $subscriptionOwnBuilder = clone $subscriptionBuilder; - - $subscriptionOwnBuilder - ->distinct() - ->leftJoin( - 'noteUser', - 'noteUser', - [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - ] - ) - ->where([ - [ - 'relatedId!=' => null, - 'relatedType=' => $onlyOwnEntityTypeList, - ], - 'noteUser.userId' => $user->getId(), - ]); - - $queryList[] = $subscriptionOwnBuilder->build(); - } - } - - $subscriptionSuperBuilder = clone $baseBuilder; - - $subscriptionSuperBuilder - ->join( - 'Subscription', - 'subscription', - [ - 'entityType:' => 'superParentType', - 'entityId:' => 'superParentId', - 'subscription.userId' => $user->getId(), - ] - ) - ->leftJoin( - 'Subscription', - 'subscriptionExclude', - [ - 'entityType:' => 'parentType', - 'entityId:' => 'parentId', - 'subscription.userId' => $user->getId(), - ] - ) - ->where([ - 'OR' => [ - 'parentId!=:' => 'superParentId', - 'parentType!=:' => 'superParentType', - ], - 'subscriptionExclude.id' => null, - ]) - ->where($subscriptionIgnoreWhereClause); - - if (!$user->isPortal()) { - $subscriptionSuperRestBuilder = clone $subscriptionSuperBuilder; - - $subscriptionSuperRestBuilder->where([ - 'OR' => [ - [ - 'relatedId!=' => null, - 'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), - ], - [ - 'relatedId=' => null, - 'parentType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), - ], - ], - ]); - - $queryList[] = $subscriptionSuperRestBuilder->build(); - - if (count($onlyTeamEntityTypeList)) { - $subscriptionSuperTeamBuilder = clone $subscriptionSuperBuilder; - - $subscriptionSuperTeamBuilder - ->distinct() - ->leftJoin( - 'noteTeam', - 'noteTeam', - [ - 'noteTeam.noteId=:' => 'id', - 'noteTeam.deleted' => false, - ] - ) - ->leftJoin( - 'noteUser', - 'noteUser', - [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - ] - ) - ->where([ - 'OR' => [ - [ - 'relatedId!=' => null, - 'relatedType=' => $onlyTeamEntityTypeList, - ], - [ - 'relatedId=' => null, - 'parentType=' => $onlyTeamEntityTypeList, - ], - ], - [ - 'OR' => [ - 'noteTeam.teamId' => $teamIdList, - 'noteUser.userId' => $user->getId(), - ], - ] - ]); - - $queryList[] = $subscriptionSuperTeamBuilder->build(); - } - - if (count($onlyOwnEntityTypeList)) { - $subscriptionSuperOwnBuilder = clone $subscriptionSuperBuilder; - - $subscriptionSuperOwnBuilder - ->distinct() - ->leftJoin( - 'noteUser', - 'noteUser', - [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - ] - ) - ->where([ - [ - 'relatedId!=' => null, - 'relatedType=' => $onlyOwnEntityTypeList, - ], - 'noteUser.userId' => $user->getId(), - ]); - - $queryList[] = $subscriptionSuperOwnBuilder->build(); - } - } - - $queryList[] = (clone $baseBuilder) - ->leftJoin('users') - ->leftJoin('createdBy') - ->where([ - 'createdById!=' => $user->getId(), - 'usersMiddle.userId' => $user->getId(), - 'parentId' => null, - 'type' => NoteEntity::TYPE_POST, - 'isGlobal' => false, - ]) - ->build(); - - if ($user->isPortal()) { - $portalIdList = $user->getLinkMultipleIdList('portals'); - - if (!empty($portalIdList)) { - - $queryList[] = (clone $baseBuilder) - ->leftJoin('portals') - ->leftJoin('createdBy') - ->where([ - 'parentId' => null, - 'portalsMiddle.portalId' => $portalIdList, - 'type' => NoteEntity::TYPE_POST, - 'isGlobal' => false, - ]) - ->build(); - } - } - - if (!empty($teamIdList)) { - $queryList[] = (clone $baseBuilder) - ->leftJoin('teams') - ->leftJoin('createdBy') - ->where([ - 'parentId' => null, - 'teamsMiddle.teamId' => $teamIdList, - 'type' => NoteEntity::TYPE_POST, - 'isGlobal' => false, - ]) - ->build(); - } - - if ($skipOwn) { - foreach ($queryList as $i => $query) { - $queryList[$i] = $this->entityManager - ->getQueryBuilder() - ->select() - ->clone($query) - ->where([ - 'createdById!=' => $this->user->getId(), - ]) - ->build(); - } - } - - $queryList[] = (clone $baseBuilder) - ->leftJoin('createdBy') - ->where([ - 'createdById' => $user->getId(), - 'parentId' => null, - 'type' => NoteEntity::TYPE_POST, - 'isGlobal' => false, - ]) - ->build(); - - if ( - (!$user->isPortal() || $user->isAdmin()) && - !$user->isApi() - ) { - - $queryList[] = (clone $baseBuilder) - ->leftJoin('createdBy') - ->where([ - 'parentId' => null, - 'type' => NoteEntity::TYPE_POST, - 'isGlobal' => true, - ]) - ->build(); - } - - $builder = $this->entityManager - ->getQueryBuilder() - ->union() - ->all() - ->order('number', 'DESC') - ->limit($offset, $maxSize + 1); - - foreach ($queryList as $query) { - $builder->query($query); - } - - $unionQuery = $builder->build(); - - $sql = $this->entityManager - ->getQueryComposer() - ->compose($unionQuery); - - $sthCollection = $this->entityManager - ->getRDBRepository(NoteEntity::ENTITY_TYPE) - ->findBySql($sql); - - $collection = $this->entityManager - ->getCollectionFactory() - ->createFromSthCollection($sthCollection); - - foreach ($collection as $e) { - $this->loadNoteAdditionalFields($e); - - $this->applyAccessControlToNote($e, $user); - } - - $total = -2; - - if (count($collection) > $maxSize) { - $total = -1; - - unset($collection[count($collection) - 1]); - } - - return (object) [ - 'total' => $total, - 'collection' => $collection, - ]; - } - - /** - * @return array - */ - private function getUserStreamSubscriptionIgnoreWhereClause(User $user): array - { - $ignoreScopeList = $this->getIgnoreScopeList($user); - - if (empty($ignoreScopeList)) { - return []; - } - - $whereClause = []; - - $whereClause[] = [ - 'OR' => [ - 'relatedType' => null, - 'relatedType!=' => $ignoreScopeList, - ] - ]; - - $whereClause[] = [ - 'OR' => [ - 'parentType' => null, - 'parentType!=' => $ignoreScopeList, - ] - ]; - - if (in_array(Email::ENTITY_TYPE, $ignoreScopeList)) { - $whereClause[] = [ - 'type!=' => [ - NoteEntity::TYPE_EMAIL_RECEIVED, - NoteEntity::TYPE_EMAIL_SENT, - ], - ]; - } - - return $whereClause; - } - - /** - * @param array{ - * offset?: int|null, - * maxSize: int|null, - * skipOwn?: bool, - * where?: ?array, - * after?: ?string, - * filter?: ?string, - * } $params - * @return array - */ - private function getUserStreamWhereClause(array $params, User $user): array - { - $whereClause = []; - - if (!empty($params['after'])) { - $whereClause[]['createdAt>'] = $params['after']; - } - - if (!empty($params['filter'])) { - switch ($params['filter']) { - case 'posts': - $whereClause[]['type'] = NoteEntity::TYPE_POST; - - break; - - case 'updates': - $whereClause[]['type'] = [ - NoteEntity::TYPE_UPDATE, - NoteEntity::TYPE_STATUS, - ]; - - break; - } - } - - return $whereClause; - } - - private function loadNoteAdditionalFields(NoteEntity $note): void - { - $note->loadAdditionalFields(); - } - - /** - * @param array{ - * offset?: int|null, - * maxSize: int|null, - * skipOwn?: bool, - * where?: ?array, - * after?: ?string, - * filter?: ?string, - * } $params - * @throws NotFound - * @throws Forbidden - */ - public function find(string $scope, ?string $id, array $params): stdClass - { - if ($scope === User::ENTITY_TYPE) { - if (empty($id)) { - $id = $this->user->getId(); - } - - return $this->findUserStream($id, $params); - } - - $entity = $this->entityManager->getEntity($scope, $id); - - $onlyTeamEntityTypeList = $this->getOnlyTeamEntityTypeList($this->user); - $onlyOwnEntityTypeList = $this->getOnlyOwnEntityTypeList($this->user); - - if (empty($entity)) { - throw new NotFound(); - } - - if (!$this->acl->checkEntity($entity, Table::ACTION_STREAM)) { - throw new Forbidden(); - } - - $additionalQuery = null; - - if (!empty($params['where'])) { - $searchParams = SearchParams::fromRaw([ - 'where' => $params['where'], - ]); - - $additionalQuery = $this->selectBuilderFactory - ->create() - ->from(NoteEntity::ENTITY_TYPE) - ->withComplexExpressionsForbidden() - ->withWherePermissionCheck() - ->withSearchParams($searchParams) - ->buildQueryBuilder() - ->order([]) - ->build(); - } - - $builder = $this->entityManager->getQueryBuilder()->select(); - - if ($additionalQuery) { - $builder->clone($additionalQuery); - } - else { - $builder->from(NoteEntity::ENTITY_TYPE); - } - - $where = [ - 'OR' => [ - [ - 'parentType' => $scope, - 'parentId' => $id, - ], - [ - 'superParentType' => $scope, - 'superParentId' => $id, - ], - ] - ]; - - if ($this->user->isPortal()) { - $where = [ - 'parentType' => $scope, - 'parentId' => $id, - 'isInternal' => false, - ]; - - $notAllEntityTypeList = $this->getNotAllEntityTypeList($this->user); - - $orGroup = [ - [ - 'relatedId' => null, - ], - [ - 'relatedId!=' => null, - 'relatedType!=' => $notAllEntityTypeList, - ], - ]; - - if ($this->acl->check(Email::ENTITY_TYPE, Table::ACTION_READ)) { - $builder->leftJoin( - 'noteUser', - 'noteUser', - [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - 'note.relatedType' => Email::ENTITY_TYPE, - ] - ); - - $orGroup[] = [ - 'relatedId!=' => null, - 'relatedType' => Email::ENTITY_TYPE, - 'noteUser.userId' => $this->user->getId(), - ]; - } - - $where[] = [ - 'OR' => $orGroup, - ]; - } - - if (!$this->user->isPortal()) { - if (count($onlyTeamEntityTypeList) || count($onlyOwnEntityTypeList)) { - $builder - ->distinct() - ->leftJoin('teams') - ->leftJoin('users'); - - $where[] = [ - 'OR' => [ - 'OR' => [ - [ - 'relatedId!=' => null, - 'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), - ], - [ - 'relatedId=' => null, - 'superParentId' => $id, - 'superParentType' => $scope, - 'parentId!=' => null, - 'parentType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), - ], - [ - 'relatedId=' => null, - 'parentType=' => $scope, - 'parentId=' => $id, - ] - ], - [ - 'OR' => [ - [ - 'relatedId!=' => null, - 'relatedType=' => $onlyTeamEntityTypeList, - ], - [ - 'relatedId=' => null, - 'parentType=' => $onlyTeamEntityTypeList, - ] - ], - [ - 'OR' => [ - 'teamsMiddle.teamId' => $this->user->getTeamIdList(), - 'usersMiddle.userId' => $this->user->getId(), - ] - ] - ], - [ - 'OR' => [ - [ - 'relatedId!=' => null, - 'relatedType=' => $onlyOwnEntityTypeList, - ], - [ - 'relatedId=' => null, - 'parentType=' => $onlyOwnEntityTypeList, - ] - ], - 'usersMiddle.userId' => $this->user->getId(), - ] - ] - ]; - } - } - - if (!empty($params['filter'])) { - switch ($params['filter']) { - case 'posts': - $where['type'] = NoteEntity::TYPE_POST; - - break; - - case 'updates': - $where['type'] = [ - NoteEntity::TYPE_ASSIGN, - NoteEntity::TYPE_STATUS, - ]; - - break; - } - } - - $ignoreScopeList = $this->getIgnoreScopeList($this->user); - - if (!empty($ignoreScopeList)) { - $where[] = [ - 'OR' => [ - 'relatedType' => null, - 'relatedType!=' => $ignoreScopeList, - ] - ]; - - $where[] = [ - 'OR' => [ - 'parentType' => null, - 'parentType!=' => $ignoreScopeList, - ] - ]; - - if (in_array(Email::ENTITY_TYPE, $ignoreScopeList)) { - $where[] = [ - 'type!=' => [ - NoteEntity::TYPE_EMAIL_RECEIVED, - NoteEntity::TYPE_EMAIL_SENT, - ] - ]; - } - } - - $builder->where($where); - - if (!empty($params['after'])) { - $builder->where([ - 'createdAt>' => $params['after'], - ]); - } - - $countBuilder = clone $builder; - - $builder - ->limit($params['offset'] ?? 0, $params['maxSize']) - ->order('number', 'DESC'); - - /** @var iterable $collection */ - $collection = $this->entityManager - ->getRDBRepository(NoteEntity::ENTITY_TYPE) - ->clone($builder->build()) - ->find(); - - foreach ($collection as $e) { - if ( - $e->getType() === NoteEntity::TYPE_POST || - $e->getType() === NoteEntity::TYPE_EMAIL_RECEIVED - ) { - $e->loadAttachments(); - } - - if ( - $e->getParentId() && $e->getParentType() && - ($e->getParentId() !== $id || $e->getParentType() !== $scope) - ) { - $e->loadParentNameField('parent'); - } - - if ($e->getRelatedId() && $e->getRelatedType()) { - $e->loadParentNameField('related'); - } - - $this->applyAccessControlToNote($e); - } - - $count = $this->entityManager - ->getRDBRepository(NoteEntity::ENTITY_TYPE) - ->clone($countBuilder->build()) - ->count(); - - return (object) [ - 'total' => $count, - 'collection' => $collection, - ]; - } - - private function loadAssignedUserName(Entity $entity): void - { - $user = $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) - ->select(['name']) - ->where([ - 'id' => $entity->get('assignedUserId'), - ]) - ->findOne(); - - if ($user) { - $entity->set('assignedUserName', $user->get('name')); - } - } - - /** - * Notes having `related` or `superParent` are subjects to access control - * through `users` and `teams` fields. - * - * When users or teams of `related` or `parent` record are changed - * the note record will be changed too. - */ - private function processNoteTeamsUsers(NoteEntity $note, Entity $entity): void - { - if (!$entity instanceof CoreEntity) { - return; - } - - $note->setAclIsProcessed(); - - $note->set('teamsIds', []); - $note->set('usersIds', []); - - if ($entity->hasLinkMultipleField('teams') && $entity->has('teamsIds')) { - $teamIdList = $entity->get('teamsIds'); - - if (!empty($teamIdList)) { - $note->set('teamsIds', $teamIdList); - } - } - - $ownerUserField = $this->aclManager->getReadOwnerUserField($entity->getEntityType()); - - if (!$ownerUserField) { - return; - } - - $defs = $this->entityManager->getDefs()->getEntity($entity->getEntityType()); - - if (!$defs->hasField($ownerUserField)) { - return; - } - - $fieldDefs = $defs->getField($ownerUserField); - - if ($fieldDefs->getType() === 'linkMultiple') { - $ownerUserIdAttribute = $ownerUserField . 'Ids'; - } - else if ($fieldDefs->getType() === 'link') { - $ownerUserIdAttribute = $ownerUserField . 'Id'; - } - else { - return; - } - - if (!$entity->has($ownerUserIdAttribute)) { - return; - } - - if ($fieldDefs->getType() === 'linkMultiple') { - $userIdList = $entity->getLinkMultipleIdList($ownerUserField); - } - else { - $userId = $entity->get($ownerUserIdAttribute); - - if (!$userId) { - return; - } - - $userIdList = [$userId]; - } - - $note->set('usersIds', $userIdList); - } - - public function noteEmailReceived(Entity $entity, Email $email, bool $isInitial = false): void - { - $entityType = $entity->getEntityType(); - - if ( - $this->entityManager - ->getRDBRepository(NoteEntity::ENTITY_TYPE) - ->where([ - 'type' => NoteEntity::TYPE_EMAIL_RECEIVED, - 'parentId' => $entity->getId(), - 'parentType' => $entityType, - 'relatedId' => $email->getId(), - 'relatedType' => Email::ENTITY_TYPE, - ]) - ->findOne() - ) { - return; - } - - /** @var NoteEntity $note */ - $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); - - $note->set('type', NoteEntity::TYPE_EMAIL_RECEIVED); - $note->set('parentId', $entity->getId()); - $note->set('parentType', $entityType); - $note->set('relatedId', $email->getId()); - $note->set('relatedType', Email::ENTITY_TYPE); - - $this->processNoteTeamsUsers($note, $email); - - if ($email->get('accountId')) { - $note->set('superParentId', $email->get('accountId')); - $note->set('superParentType', 'Account'); - } - - $withContent = in_array($entityType, $this->config->get('streamEmailWithContentEntityTypeList', [])); - - if ($withContent) { - $note->set('post', $email->getBodyPlain()); - } - - $data = []; - - $data['emailId'] = $email->getId(); - $data['emailName'] = $email->get('name'); - $data['isInitial'] = $isInitial; - - if ($withContent) { - $data['attachmentsIds'] = $email->get('attachmentsIds'); - } - - $from = $email->get('from'); - - if ($from) { - $person = $this->getEmailAddressRepository()->getEntityByAddress($from); - - if ($person) { - $data['personEntityType'] = $person->getEntityType(); - $data['personEntityName'] = $person->get('name'); - $data['personEntityId'] = $person->getId(); - } - } - - $note->set('data', (object) $data); - - $this->entityManager->saveEntity($note); - } - - public function noteEmailSent(Entity $entity, Email $email): void - { - $entityType = $entity->getEntityType(); - - /** @var NoteEntity $note */ - $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); - - $note->set('type', NoteEntity::TYPE_EMAIL_SENT); - $note->set('parentId', $entity->getId()); - $note->set('parentType', $entityType); - $note->set('relatedId', $email->getId()); - $note->set('relatedType', Email::ENTITY_TYPE); - - $this->processNoteTeamsUsers($note, $email); - - if ($email->get('accountId')) { - $note->set('superParentId', $email->get('accountId')); - $note->set('superParentType', 'Account'); - } - - $withContent = in_array($entityType, $this->config->get('streamEmailWithContentEntityTypeList', [])); - - if ($withContent) { - $note->set('post', $email->getBodyPlain()); - } - - $data = []; - - $data['emailId'] = $email->getId(); - $data['emailName'] = $email->get('name'); - - if ($withContent) { - $data['attachmentsIds'] = $email->get('attachmentsIds'); - } - - $user = $this->user; - - $person = null; - - if (!$user->isSystem()) { - $person = $user; - } - else { - $from = $email->get('from'); - - if ($from) { - $person = $this->getEmailAddressRepository()->getEntityByAddress($from); - } - } - - if ($person) { - $data['personEntityType'] = $person->getEntityType(); - $data['personEntityName'] = $person->get('name'); - $data['personEntityId'] = $person->getId(); - } - - $note->set('data', (object) $data); - - $this->entityManager->saveEntity($note); - } - - /** - * @param array $options - */ - public function noteCreate(Entity $entity, array $options = []): void - { - $entityType = $entity->getEntityType(); - - /** @var NoteEntity $note */ - $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); - - $note->set('type', NoteEntity::TYPE_CREATE); - $note->set('parentId', $entity->getId()); - $note->set('parentType', $entityType); - - if ($entity->has('accountId') && $entity->get('accountId')) { - $note->set('superParentId', $entity->get('accountId')); - $note->set('superParentType', 'Account'); - - // only if has super parent - $this->processNoteTeamsUsers($note, $entity); - } - - $data = []; - - if ($entity->get('assignedUserId')) { - if (!$entity->has('assignedUserName')) { - $this->loadAssignedUserName($entity); - } - - $data['assignedUserId'] = $entity->get('assignedUserId'); - $data['assignedUserName'] = $entity->get('assignedUserName'); - } - - $statusFields = $this->getStatusFields(); - - if (!empty($statusFields[$entityType])) { - $field = $statusFields[$entityType]; - $value = $entity->get($field); - - if (!empty($value)) { - $data['statusValue'] = $value; - $data['statusField'] = $field; - $data['statusStyle'] = $this->getStatusStyle($entityType, $field, $value); - } - } - - $note->set('data', (object) $data); - - $o = []; - - if (!empty($options['createdById'])) { - $o['createdById'] = $options['createdById']; - } - - $this->entityManager->saveEntity($note, $o); - } - - /** - * @param mixed $value - */ - private function getStatusStyle(string $entityType, string $field, $value): string - { - $style = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'style', $value]); - - if ($style) { - return $style; - } - - $statusStyles = $this->getStatusStyles(); - - if (isset($statusStyles[$entityType]) && isset($statusStyles[$entityType][$value])) { - return (string) $statusStyles[$entityType][$value]; - } - - if (in_array($value, $this->successDefaultStyleList)) { - return 'success'; - } - - if (in_array($value, $this->dangerDefaultStyleList)) { - return 'danger'; - } - - return 'default'; - } - - /** - * @param array $options - */ - public function noteCreateRelated( - Entity $entity, - string $parentType, - string $parentId, - array $options = [] - ): void { - - /** @var NoteEntity $note */ - $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); - - $entityType = $entity->getEntityType(); - - $note->set('type', NoteEntity::TYPE_CREATE_RELATED); - $note->set('parentId', $parentId); - $note->set('parentType', $parentType); - $note->set([ - 'relatedType' => $entityType, - 'relatedId' => $entity->getId(), - ]); - - $this->processNoteTeamsUsers($note, $entity); - - if ($entity->has('accountId') && $entity->get('accountId')) { - $note->set('superParentId', $entity->get('accountId')); - $note->set('superParentType', 'Account'); - } - - $o = []; - - if (!empty($options['createdById'])) { - $o['createdById'] = $options['createdById']; - } - - $this->entityManager->saveEntity($note, $o); - } - - /** - * @param array $options - */ - public function noteRelate(Entity $entity, string $parentType, string $parentId, array $options = []): void - { - $entityType = $entity->getEntityType(); - - $existing = $this->entityManager - ->getRDBRepository(NoteEntity::ENTITY_TYPE) - ->select(['id']) - ->where([ - 'type' => NoteEntity::TYPE_RELATE, - 'parentId' => $parentId, - 'parentType' => $parentType, - 'relatedId' => $entity->getId(), - 'relatedType' => $entityType, - ]) - ->findOne(); - - if ($existing) { - return; - } - - /** @var NoteEntity $note */ - $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); - - $note->set([ - 'type' => NoteEntity::TYPE_RELATE, - 'parentId' => $parentId, - 'parentType' => $parentType, - 'relatedType' => $entityType, - 'relatedId' => $entity->getId(), - ]); - - $this->processNoteTeamsUsers($note, $entity); - - $o = []; - - if (!empty($options['createdById'])) { - $o['createdById'] = $options['createdById']; - } - - $this->entityManager->saveEntity($note, $o); - } - - /** - * @param array $options - */ - public function noteAssign(Entity $entity, array $options = []): void - { - /** @var NoteEntity $note */ - $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); - - $note->set('type', NoteEntity::TYPE_ASSIGN); - $note->set('parentId', $entity->getId()); - $note->set('parentType', $entity->getEntityType()); - - if ($entity->has('accountId') && $entity->get('accountId')) { - $note->set('superParentId', $entity->get('accountId')); - $note->set('superParentType', 'Account'); - - // only if has super parent - $this->processNoteTeamsUsers($note, $entity); - } - - if ($entity->get('assignedUserId')) { - if (!$entity->has('assignedUserName')) { - $this->loadAssignedUserName($entity); - } - - $note->set('data', [ - 'assignedUserId' => $entity->get('assignedUserId'), - 'assignedUserName' => $entity->get('assignedUserName'), - ]); - } else { - $note->set('data', [ - 'assignedUserId' => null - ]); - } - - $o = []; - - if (!empty($options['createdById'])) { - $o['createdById'] = $options['createdById']; - } - - if (!empty($options['modifiedById'])) { - $o['createdById'] = $options['modifiedById']; - } - - $this->entityManager->saveEntity($note, $o); - } - - /** - * @param array $options - */ - public function noteStatus(Entity $entity, string $field, array $options = []): void - { - /** @var NoteEntity $note */ - $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); - - $note->set('type', NoteEntity::TYPE_STATUS); - $note->set('parentId', $entity->getId()); - $note->set('parentType', $entity->getEntityType()); - - if ($entity->has('accountId') && $entity->get('accountId')) { - $note->set('superParentId', $entity->get('accountId')); - $note->set('superParentType', 'Account'); - - $this->processNoteTeamsUsers($note, $entity); - } - - $entityType = $entity->getEntityType(); - - $value = $entity->get($field); - - $style = $this->getStatusStyle($entityType, $field, $value); - - $note->set('data', [ - 'field' => $field, - 'value' => $value, - 'style' => $style, - ]); - - $o = []; - - if (!empty($options['createdById'])) { - $o['createdById'] = $options['createdById']; - } - - if (!empty($options['modifiedById'])) { - $o['createdById'] = $options['modifiedById']; - } - - $this->entityManager->saveEntity($note, $o); - } - - /** - * @return array< - * string, - * array{ - * actualList: string[], - * notActualList: string[], - * fieldType: string, - * } - * > - */ - private function getAuditedFieldsData(Entity $entity): array - { - $entityType = $entity->getEntityType(); - - $statusFields = $this->getStatusFields(); - - if (array_key_exists($entityType, $this->auditedFieldsCache)) { - return $this->auditedFieldsCache[$entityType]; - } - - /** @var array> $fields */ - $fields = $this->metadata->get('entityDefs.' . $entityType . '.fields'); - - $auditedFields = []; - - foreach ($fields as $field => $defs) { - if (empty($defs['audited'])) { - continue; - } - - if (!empty($statusFields[$entityType]) && $statusFields[$entityType] === $field) { - continue; - } - - /** @var ?string $type */ - $type = $defs['type'] ?? null; - - if (!$type) { - continue; - } - - $auditedFields[$field] = []; - - $auditedFields[$field]['actualList'] = - $this->fieldUtil->getActualAttributeList($entityType, $field); - - $auditedFields[$field]['notActualList'] = - $this->fieldUtil->getNotActualAttributeList($entityType, $field); - - $auditedFields[$field]['fieldType'] = $type; - } - - $this->auditedFieldsCache[$entityType] = $auditedFields; - - return $this->auditedFieldsCache[$entityType]; - } - - /** - * @param array $options - */ - public function handleAudited(Entity $entity, array $options = []): void - { - $auditedFields = $this->getAuditedFieldsData($entity); - - $updatedFieldList = []; - - $was = []; - $became = []; - - $entityDefs = $this->entityManager - ->getDefs() - ->getEntity($entity->getEntityType()); - - foreach ($auditedFields as $field => $item) { - $updated = false; - - foreach ($item['actualList'] as $attribute) { - if ($entity->hasFetched($attribute) && $entity->isAttributeChanged($attribute)) { - $updated = true; - - break; - } - } - - if (!$updated) { - continue; - } - - $updatedFieldList[] = $field; - - $fieldDefs = $entityDefs->hasField($field) ? $entityDefs->getField($field) : null; - - if ( - $fieldDefs && - in_array($fieldDefs->getType(), ['text', 'wysiwyg']) - ) { - continue; - } - - foreach ($item['actualList'] as $attribute) { - $was[$attribute] = $entity->getFetched($attribute); - $became[$attribute] = $entity->get($attribute); - } - - foreach ($item['notActualList'] as $attribute) { - $was[$attribute] = $entity->getFetched($attribute); - $became[$attribute] = $entity->get($attribute); - } - - if ($item['fieldType'] === 'linkParent') { - $wasParentType = $was[$field . 'Type']; - $wasParentId = $was[$field . 'Id']; - - if ($wasParentType && $wasParentId && $this->entityManager->hasRepository($wasParentType)) { - $wasParent = $this->entityManager->getEntity($wasParentType, $wasParentId); - - if ($wasParent) { - $was[$field . 'Name'] = $wasParent->get('name'); - } - } - } - } - - if (count($updatedFieldList) === 0) { - return; - } - - /** @var NoteEntity $note */ - $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); - - $note->set('type', NoteEntity::TYPE_UPDATE); - $note->set('parentId', $entity->getId()); - $note->set('parentType', $entity->getEntityType()); - - $note->set('data', [ - 'fields' => $updatedFieldList, - 'attributes' => [ - 'was' => (object) $was, - 'became' => (object) $became, - ], - ]); - - $o = []; - - if (!empty($options['modifiedById'])) { - $o['createdById'] = $options['modifiedById']; - } - - $this->entityManager->saveEntity($note, $o); - } - - /** - * @return string[] - */ - public function getEntityFolowerIdList(Entity $entity): array - { - $userList = $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) - ->select(['id']) - ->join('Subscription', 'subscription', [ - 'subscription.userId=:' => 'user.id', - 'subscription.entityId' => $entity->getId(), - 'subscription.entityType' => $entity->getEntityType(), - ]) - ->where(['isActive' => true]) - ->find(); - - $idList = []; - - foreach ($userList as $user) { - $idList[] = $user->getId(); - } - - return $idList; - } - - /** - * @return RecordCollection - */ - public function findEntityFollowers(Entity $entity, SearchParams $searchParams): RecordCollection - { - $builder = $this->selectBuilderFactory - ->create() - ->from(User::ENTITY_TYPE) - ->withSearchParams($searchParams) - ->withStrictAccessControl() - ->buildQueryBuilder(); - - if (!$searchParams->getOrderBy()) { - $builder->order('LIST:id:' . $this->user->getId(), 'DESC'); - $builder->order('name'); - } - - $builder->join( - 'Subscription', - 'subscription', - [ - 'subscription.userId=:' => 'user.id', - 'subscription.entityId' => $entity->getId(), - 'subscription.entityType' => $entity->getEntityType(), - ] - ); - - $query = $builder->build(); - - /** @var \Espo\ORM\Collection $collection */ - $collection = $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) - ->clone($query) - ->find(); - - $total = $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) - ->clone($query) - ->count(); - - $userService = $this->recordServiceContainer->get(User::ENTITY_TYPE); - - foreach ($collection as $e) { - $userService->prepareEntityForOutput($e); - } - - /** @var RecordCollection */ - return new RecordCollection($collection, $total); - } - - /** - * @return array{ - * idList: string[], - * nameMap: stdClass, - * } - */ - public function getEntityFollowers(Entity $entity, int $offset = 0, ?int $limit = null): array - { - if (!$limit) { - $limit = 200; - } - - $userList = $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) - ->select(['id', 'name']) - ->join( - 'Subscription', - 'subscription', - [ - 'subscription.userId=:' => 'user.id', - 'subscription.entityId' => $entity->getId(), - 'subscription.entityType' => $entity->getEntityType() - ] - ) - ->limit($offset, $limit) - ->where([ - 'isActive' => true, - ]) - ->order([ - ['LIST:id:' . $this->user->getId(), 'DESC'], - ['name'], - ]) - ->find(); - - $data = [ - 'idList' => [], - 'nameMap' => (object) [], - ]; - - foreach ($userList as $user) { - /** @var string $id */ - $id = $user->getId(); - - $data['idList'][] = $id; - $data['nameMap']->$id = $user->get('name'); - } - - return $data; - } - - /** - * @return string[] - */ - private function getOnlyTeamEntityTypeList(User $user): array - { - if ($user->isPortal()) { - return []; - } - - $list = []; - - $scopes = $this->metadata->get('scopes', []); - - foreach ($scopes as $scope => $item) { - if ($scope === User::ENTITY_TYPE) { - continue; - } - - if (empty($item['entity'])) { - continue; - } - - if (empty($item['object'])) { - continue; - } - - if ( - $this->aclManager->checkReadOnlyTeam($user, $scope) - ) { - $list[] = $scope; - } - } - - return $list; - } - - /** - * @return string[] - */ - private function getOnlyOwnEntityTypeList(User $user): array - { - if ($user->isPortal()) { - return []; - } - - $list = []; - - $scopes = $this->metadata->get('scopes', []); - - foreach ($scopes as $scope => $item) { - if ($scope === User::ENTITY_TYPE) { - continue; - } - - if (empty($item['entity'])) { - continue; - } - - if (empty($item['object'])) { - continue; - } - - if ( - $this->aclManager->checkReadOnlyOwn($user, $scope) - ) { - $list[] = $scope; - } - } - - return $list; - } - - private function getUserAclManager(User $user): ?AclManager - { - try { - return $this->userAclManagerProvider->get($user); - } - catch (Error $e) { - return null; - } - } - - /** - * @return string[] - */ - private function getNotAllEntityTypeList(User $user): array - { - if (!$user->isPortal()) { - return []; - } - - $aclManager = $this->getUserAclManager($user); - - $list = []; - - $scopes = $this->metadata->get('scopes', []); - - foreach ($scopes as $scope => $item) { - if ($scope === User::ENTITY_TYPE) { - continue; - } - - if (empty($item['entity'])) { - continue; - } - - if (empty($item['object'])) { - continue; - } - - if ( - !$aclManager || - $aclManager->getLevel($user, $scope, Table::ACTION_READ) !== Table::LEVEL_ALL - ) { - $list[] = $scope; - } - } - - return $list; - } - - /** - * @return string[] - */ - private function getIgnoreScopeList(User $user): array - { - $ignoreScopeList = []; - - $scopes = $this->metadata->get('scopes', []); - - $aclManager = $this->getUserAclManager($user); - - foreach ($scopes as $scope => $item) { - if (empty($item['entity'])) { - continue; - } - - if (empty($item['object'])) { - continue; - } - - try { - $hasAccess = - $aclManager && - $aclManager->checkScope($user, $scope, Table::ACTION_READ) && - $aclManager->checkScope($user, $scope, Table::ACTION_STREAM); - } - catch (AclNotImplemented $e) { - $hasAccess = false; - } - - if (!$hasAccess) { - $ignoreScopeList[] = $scope; - } - } - - return $ignoreScopeList; - } - - /** - * @return Collection - */ - public function getSubscriberList(string $parentType, string $parentId, bool $isInternal = false): Collection - { - if (!$this->metadata->get(['scopes', $parentType, 'stream'])) { - /** @var Collection */ - return $this->entityManager->getCollectionFactory()->create(User::ENTITY_TYPE, []); - } - - $builder = $this->entityManager - ->getQueryBuilder() - ->select() - ->from('Subscription') - ->select('userId') - ->where([ - 'entityId' => $parentId, - 'entityType' => $parentType, - ]); - - if ($isInternal) { - $builder - ->join(User::ENTITY_TYPE, 'user', ['user.id:' => 'userId']) - ->where([ - 'user.type!=' => User::TYPE_PORTAL, - ]); - } - - $subQuery = $builder->build(); - - /** @var Collection */ - return $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) - ->where([ - 'isActive' => true, - 'id=s' => $subQuery->getRaw(), - ]) - ->select(['id', 'type']) - ->find(); - } - - public function processNoteAclJob(stdClass $data): void - { - $targetType = $data->targetType; - $targetId = $data->targetId; - - if ($targetType && $targetId && $this->entityManager->hasRepository($targetType)) { - $entity = $this->entityManager->getEntity($targetType, $targetId); - - if ($entity) { - $this->processNoteAcl($entity, true); - } - } - } - - /** - * Changes users and teams of notes related to an entity according users and teams of the entity. - * - * Notes having `related` or `superParent` are subjects to access control - * through `users` and `teams` fields. - * - * When users or teams of `related` or `parent` record are changed - * the note record will be changed too. - */ - public function processNoteAcl(Entity $entity, bool $forceProcessNoteNotifications = false): void - { - if (!$entity instanceof CoreEntity) { - return; - } - - $entityType = $entity->getEntityType(); - - if (in_array($entityType, ['Note', 'User', 'Team', 'Role', 'Portal', 'PortalRole'])) { - return; - } - - if (!$this->metadata->get(['scopes', $entityType, 'acl'])) { - return; - } - - if (!$this->metadata->get(['scopes', $entityType, 'object'])) { - return; - } - - $usersAttributeIsChanged = false; - $teamsAttributeIsChanged = false; - - $ownerUserField = $this->aclManager->getReadOwnerUserField($entityType); - - /* @var \Espo\ORM\Defs\EntityDefs $defs */ - $defs = $this->entityManager->getDefs()->getEntity($entity->getEntityType()); - - $userIdList = []; - $teamIdList = []; - - if ($ownerUserField) { - if (!$defs->hasField($ownerUserField)) { - throw new LogicException("Non-existing read-owner user field."); - } - - $fieldDefs = $defs->getField($ownerUserField); - - if ($fieldDefs->getType() === 'linkMultiple') { - $ownerUserIdAttribute = $ownerUserField . 'Ids'; - } - else if ($fieldDefs->getType() === 'link') { - $ownerUserIdAttribute = $ownerUserField . 'Id'; - } - else { - throw new LogicException("Bad read-owner user field type."); - } - - if ($entity->isAttributeChanged($ownerUserIdAttribute)) { - $usersAttributeIsChanged = true; - } - - if ($usersAttributeIsChanged || $forceProcessNoteNotifications) { - if ($fieldDefs->getType() === 'linkMultiple') { - $userIdList = $entity->getLinkMultipleIdList($ownerUserField) ?? []; - } - else { - $userId = $entity->get($ownerUserIdAttribute); - - $userIdList = $userId ? [$userId] : []; - } - } - } - - if ($entity->hasLinkMultipleField('teams')) { - if ($entity->isAttributeChanged('teamsIds')) { - $teamsAttributeIsChanged = true; - } - - if ($teamsAttributeIsChanged || $forceProcessNoteNotifications) { - $teamIdList = $entity->getLinkMultipleIdList('teams') ?? []; - } - } - - if (!$usersAttributeIsChanged && !$teamsAttributeIsChanged && !$forceProcessNoteNotifications) { - return; - } - - $limit = $this->config->get('noteAclLimit', self::NOTE_ACL_LIMIT); - - $noteList = $this->entityManager - ->getRDBRepository(NoteEntity::ENTITY_TYPE) - ->where([ - 'OR' => [ - [ - 'relatedId' => $entity->getId(), - 'relatedType' => $entityType, - ], - [ - 'parentId' => $entity->getId(), - 'parentType' => $entityType, - 'superParentId!=' => null, - 'relatedId' => null, - ] - ] - ]) - ->select([ - 'id', - 'parentType', - 'parentId', - 'superParentType', - 'superParentId', - 'isInternal', - 'relatedType', - 'relatedId', - 'createdAt', - ]) - ->order('number', 'DESC') - ->limit(0, $limit) - ->find(); - - $notificationPeriod = '-' . $this->config->get('noteNotificationPeriod', self::NOTE_NOTIFICATION_PERIOD); - $aclPeriod = '-' . $this->config->get('noteAclPeriod', self::NOTE_ACL_PERIOD); - - $notificationThreshold = (new DateTime())->modify($notificationPeriod); - $aclThreshold = (new DateTime())->modify($aclPeriod); - - foreach ($noteList as $note) { - $this->processNoteAclItem($entity, $note, [ - 'teamsAttributeIsChanged' => $teamsAttributeIsChanged, - 'usersAttributeIsChanged' => $usersAttributeIsChanged, - 'forceProcessNoteNotifications' => $forceProcessNoteNotifications, - 'teamIdList' => $teamIdList, - 'userIdList' => $userIdList, - 'notificationThreshold' => $notificationThreshold, - 'aclThreshold' => $aclThreshold, - ]); - } - } - - /** - * @param array{ - * teamsAttributeIsChanged: bool, - * usersAttributeIsChanged: bool, - * forceProcessNoteNotifications: bool, - * teamIdList: string[], - * userIdList: string[], - * notificationThreshold: \DateTimeInterface, - * aclThreshold: \DateTimeInterface, - * } $params - * @return void - */ - private function processNoteAclItem(Entity $entity, NoteEntity $note, array $params): void - { - $teamsAttributeIsChanged = $params['teamsAttributeIsChanged']; - $usersAttributeIsChanged = $params['usersAttributeIsChanged']; - $forceProcessNoteNotifications = $params['forceProcessNoteNotifications']; - - $teamIdList = $params['teamIdList']; - $userIdList = $params['userIdList']; - - $notificationThreshold = $params['notificationThreshold']; - $aclThreshold = $params['aclThreshold']; - - $createdAt = $note->getCreatedAt(); - - if (!$createdAt) { - return; - } - - if (!$entity->isNew()) { - if ($createdAt->getTimestamp() < $notificationThreshold->getTimestamp()) { - $forceProcessNoteNotifications = false; - } - - if ($createdAt->getTimestamp() < $aclThreshold->getTimestamp()) { - return; - } - } - - if ($teamsAttributeIsChanged || $forceProcessNoteNotifications) { - $note->set('teamsIds', $teamIdList); - } - - if ($usersAttributeIsChanged || $forceProcessNoteNotifications) { - $note->set('usersIds', $userIdList); - } - - $this->entityManager->saveEntity($note, [ - 'forceProcessNotifications' => $forceProcessNoteNotifications, - ]); - } - - public function applyAccessControlToNote(NoteEntity $note, ?User $user = null): void - { - if (!$user) { - $user = $this->user; - } - - $this->noteAccessControl->apply($note, $user); - } - - private function getEmailAddressRepository(): EmailAddressRepository - { - /** @var EmailAddressRepository */ - return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); - } -} +/** + * For backward compatibility. + */ +class Stream extends \Espo\Tools\Stream\Service {} diff --git a/application/Espo/Tools/Email/SendService.php b/application/Espo/Tools/Email/SendService.php index 5d09b00514..d2780c2df1 100644 --- a/application/Espo/Tools/Email/SendService.php +++ b/application/Espo/Tools/Email/SendService.php @@ -55,7 +55,7 @@ use Espo\ORM\EntityManager; use Espo\Repositories\UserData as UserDataRepository; use Espo\Services\EmailAccount as EmailAccountService; use Espo\Services\InboundEmail as InboundEmailService; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Exception; use Laminas\Mail\Message; use Throwable; diff --git a/application/Espo/Tools/Notification/HookProcessor.php b/application/Espo/Tools/Notification/HookProcessor.php index a297e6ebb4..4d322b2655 100644 --- a/application/Espo/Tools/Notification/HookProcessor.php +++ b/application/Espo/Tools/Notification/HookProcessor.php @@ -36,7 +36,7 @@ use Espo\Core\Notification\AssignmentNotificator\Params as AssignmentNotificator use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Config; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\ORM\EntityManager; use Espo\ORM\Entity; @@ -62,11 +62,8 @@ class HookProcessor private $hasStreamCache = []; private Metadata $metadata; - private Config $config; - private EntityManager $entityManager; - private StreamService $streamService; private AssignmentNotificatorFactory $notificatorFactory; diff --git a/application/Espo/Tools/Notification/NoteHookProcessor.php b/application/Espo/Tools/Notification/NoteHookProcessor.php index 1d67ecc004..9555956a2a 100644 --- a/application/Espo/Tools/Notification/NoteHookProcessor.php +++ b/application/Espo/Tools/Notification/NoteHookProcessor.php @@ -32,7 +32,7 @@ namespace Espo\Tools\Notification; use Espo\Core\AclManager as InternalAclManager; use Espo\Core\Acl\Table; -use Espo\Services\Stream as StreamService; +use Espo\Tools\Stream\Service as StreamService; use Espo\ORM\EntityManager; use Espo\ORM\Collection; @@ -50,15 +50,11 @@ use Espo\Entities\Note; */ class NoteHookProcessor { - private $streamService; - - private $service; - - private $entityManager; - - private $user; - - private $internalAclManager; + private StreamService $streamService; + private Service $service; + private EntityManager $entityManager; + private User $user; + private InternalAclManager $internalAclManager; public function __construct( StreamService $streamService, diff --git a/application/Espo/Tools/Stream/HookProcessor.php b/application/Espo/Tools/Stream/HookProcessor.php index f9f9642373..2f4f83e7b8 100644 --- a/application/Espo/Tools/Stream/HookProcessor.php +++ b/application/Espo/Tools/Stream/HookProcessor.php @@ -30,7 +30,7 @@ namespace Espo\Tools\Stream; use Espo\Core\Utils\Metadata; -use Espo\Services\Stream as Service; +use Espo\Tools\Stream\Service as Service; use Espo\Entities\User; use Espo\Entities\Preferences; use Espo\Entities\Note; @@ -53,31 +53,18 @@ use Espo\Core\ORM\Entity as CoreEntity; */ class HookProcessor { - /** - * @var array - */ + /** @var array */ private $hasStreamCache = []; - - /** - * @var array - */ + /** @var array */ private $isLinkObservableInStreamCache = []; - - /** - * @var ?array - */ + /** @var ?array */ private $statusFields = null; private Metadata $metadata; - private EntityManager $entityManager; - private Service $service; - private User $user; - private Preferences $preferences; - private JobSchedulerFactory $jobSchedulerFactory; public function __construct( @@ -508,8 +495,6 @@ class HookProcessor if ($this->user->getId() === $assignedUserId) { $entity->set('isFollowed', true); } - - return; } private function afterSaveStreamNotNew2(CoreEntity $entity): void @@ -633,9 +618,9 @@ class HookProcessor if ($audited) { $note1 = $this->entityManager - ->getRDBRepository('Note') + ->getRDBRepository(Note::ENTITY_TYPE) ->where([ - 'type' => 'Relate', + 'type' => Note::TYPE_RELATE, 'parentId' => $entity->getId(), 'parentType' => $entityType, 'relatedId' => $foreignEntity->getId(), @@ -650,9 +635,9 @@ class HookProcessor if ($auditedForeign) { $note2 = $this->entityManager - ->getRDBRepository('Note') + ->getRDBRepository(Note::ENTITY_TYPE) ->where([ - 'type' => 'Relate', + 'type' => Note::TYPE_RELATE, 'parentId' => $foreignEntity->getId(), 'parentType' => $foreignEntityType, 'relatedId' => $entity->getId(), diff --git a/application/Espo/Tools/Stream/Jobs/AutoFollow.php b/application/Espo/Tools/Stream/Jobs/AutoFollow.php index 14205d93d3..223664f86e 100644 --- a/application/Espo/Tools/Stream/Jobs/AutoFollow.php +++ b/application/Espo/Tools/Stream/Jobs/AutoFollow.php @@ -35,9 +35,11 @@ use Espo\Core\Job\Job\Data; use Espo\Core\AclManager; use Espo\Core\Acl\Exceptions\NotImplemented as AclNotImplemented; +use Espo\Entities\Note; +use Espo\ORM\Collection; use Espo\ORM\EntityManager; -use Espo\Services\Stream as Service; +use Espo\Tools\Stream\Service as Service; use Espo\Tools\Notification\Service as NotificationService; use Espo\Entities\User; @@ -47,13 +49,10 @@ use Espo\Entities\User; */ class AutoFollow implements Job { - private $service; - - private $notificationService; - - private $aclManager; - - private $entityManager; + private Service $service; + private NotificationService $notificationService; + private AclManager $aclManager; + private EntityManager $entityManager; public function __construct( Service $service, @@ -69,6 +68,7 @@ class AutoFollow implements Job public function run(Data $data): void { + /** @var string[] $userIdList */ $userIdList = $data->get('userIdList') ?? []; $entityType = $data->get('entityType'); $entityId = $data->get('entityId'); @@ -77,7 +77,7 @@ class AutoFollow implements Job return; } - $entity = $this->entityManager->getEntity($entityType, $entityId); + $entity = $this->entityManager->getEntityById($entityType, $entityId); if (!$entity) { return; @@ -85,7 +85,7 @@ class AutoFollow implements Job foreach ($userIdList as $i => $userId) { /** @var User|null $user */ - $user = $this->entityManager->getEntity('User', $userId); + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId); if (!$user) { unset($userIdList[$i]); @@ -121,9 +121,9 @@ class AutoFollow implements Job $this->service->followEntityMass($entity, $userIdList); - /** @var iterable<\Espo\Entities\Note> $noteList */ + /** @var Collection $noteList */ $noteList = $this->entityManager - ->getRDBRepository('Note') + ->getRDBRepository(Note::ENTITY_TYPE) ->where([ 'parentType' => $entityType, 'parentId' => $entityId, diff --git a/application/Espo/Tools/Stream/Jobs/ControlFollowers.php b/application/Espo/Tools/Stream/Jobs/ControlFollowers.php index 6848228676..b61a9d9a88 100644 --- a/application/Espo/Tools/Stream/Jobs/ControlFollowers.php +++ b/application/Espo/Tools/Stream/Jobs/ControlFollowers.php @@ -37,7 +37,7 @@ use Espo\Core\Acl\Exceptions\NotImplemented as AclNotImplemented; use Espo\ORM\EntityManager; -use Espo\Services\Stream as Service; +use Espo\Tools\Stream\Service as Service; use Espo\Entities\User; /** @@ -45,11 +45,9 @@ use Espo\Entities\User; */ class ControlFollowers implements Job { - private $service; - - private $aclManager; - - private $entityManager; + private Service $service; + private AclManager $aclManager; + private EntityManager $entityManager; public function __construct( Service $service, @@ -76,7 +74,7 @@ class ControlFollowers implements Job return; } - $idList = $this->service->getEntityFolowerIdList($entity); + $idList = $this->service->getEntityFollowerIdList($entity); $userList = $this->entityManager ->getRDBRepository(User::ENTITY_TYPE) diff --git a/application/Espo/Tools/Stream/Jobs/ProcessNoteAcl.php b/application/Espo/Tools/Stream/Jobs/ProcessNoteAcl.php new file mode 100644 index 0000000000..49f5025900 --- /dev/null +++ b/application/Espo/Tools/Stream/Jobs/ProcessNoteAcl.php @@ -0,0 +1,73 @@ +service = $service; + $this->entityManager = $entityManager; + } + + public function run(Data $data): void + { + $targetType = $data->getTargetType(); + $targetId = $data->getTargetId(); + + if (!$targetType || !$targetId) { + return; + } + + if (!$this->entityManager->hasRepository($targetType)) { + return; + } + + $entity = $this->entityManager->getEntityById($targetType, $targetId); + + if (!$entity) { + return; + } + + $this->service->processNoteAcl($entity, true); + } +} diff --git a/application/Espo/Tools/Stream/Service.php b/application/Espo/Tools/Stream/Service.php new file mode 100644 index 0000000000..96af318e51 --- /dev/null +++ b/application/Espo/Tools/Stream/Service.php @@ -0,0 +1,2396 @@ + */ + private $statusStyles = null; + /** @var ?array */ + private $statusFields = null; + /** @var string[] */ + private $successDefaultStyleList = [ + 'Held', + 'Closed Won', + 'Closed', + 'Completed', + 'Complete', + 'Sold', + ]; + /** @var string[] */ + private $dangerDefaultStyleList = [ + 'Not Held', + 'Closed Lost', + 'Dead', + ]; + + /** + * + * @var array< + * string, + * array< + * string, + * array{ + * actualList: string[], + * notActualList: string[], + * fieldType: string, + * } + * > + * > + */ + private $auditedFieldsCache = []; + + /** + * When a record is re-assigned, ACL will be recalculated for related notes + * created within the period. + */ + private const NOTE_ACL_PERIOD = '3 days'; + + private const NOTE_ACL_LIMIT = 50; + + private const SYSTEM_USER_ID = 'system'; + + /** + * Not used currently. + */ + private const NOTE_NOTIFICATION_PERIOD = '1 hour'; + + private EntityManager $entityManager; + private Config $config; + private User $user; + private Metadata $metadata; + private Acl $acl; + private AclManager $aclManager; + private FieldUtil $fieldUtil; + private SelectBuilderFactory $selectBuilderFactory; + private UserAclManagerProvider $userAclManagerProvider; + private NoteAccessControl $noteAccessControl; + private RecordServiceContainer $recordServiceContainer; + + public function __construct( + EntityManager $entityManager, + Config $config, + User $user, + Metadata $metadata, + Acl $acl, + AclManager $aclManager, + FieldUtil $fieldUtil, + SelectBuilderFactory $selectBuilderFactory, + UserAclManagerProvider $userAclManagerProvider, + NoteAccessControl $noteAccessControl, + RecordServiceContainer $recordServiceContainer + ) { + $this->entityManager = $entityManager; + $this->config = $config; + $this->user = $user; + $this->metadata = $metadata; + $this->acl = $acl; + $this->aclManager = $aclManager; + $this->fieldUtil = $fieldUtil; + $this->selectBuilderFactory = $selectBuilderFactory; + $this->userAclManagerProvider = $userAclManagerProvider; + $this->noteAccessControl = $noteAccessControl; + $this->recordServiceContainer = $recordServiceContainer; + } + + /** + * @return array + */ + private function getStatusStyles(): array + { + if (empty($this->statusStyles)) { + $this->statusStyles = $this->metadata->get('entityDefs.Note.statusStyles', []); + } + + return $this->statusStyles; + } + + /** + * @return array + */ + private function getStatusFields(): array + { + if (is_null($this->statusFields)) { + $this->statusFields = []; + + /** @var array> $scopes */ + $scopes = $this->metadata->get('scopes', []); + + foreach ($scopes as $scope => $data) { + /** @var ?string $statusField */ + $statusField = $data['statusField'] ?? null; + + if (!$statusField) { + continue; + } + + $this->statusFields[$scope] = $statusField; + } + } + + return $this->statusFields; + } + + public function checkIsFollowed(Entity $entity, ?string $userId = null): bool + { + if (!$userId) { + $userId = $this->user->getId(); + } + + $isFollowed = (bool) $this->entityManager + ->getRDBRepository('Subscription') + ->select(['id']) + ->where([ + 'userId' => $userId, + 'entityType' => $entity->getEntityType(), + 'entityId' => $entity->getId(), + ]) + ->findOne(); + + return $isFollowed; + } + + /** + * @param string[] $sourceUserIdList + * + * @internal Must be left for bc. + */ + public function followEntityMass(Entity $entity, array $sourceUserIdList, bool $skipAclCheck = false): void + { + if (!$this->metadata->get(['scopes', $entity->getEntityType(), 'stream'])) { + return; + } + + $userIdList = []; + + foreach ($sourceUserIdList as $id) { + if ($id === self::SYSTEM_USER_ID) { + continue; + } + + $userIdList[] = $id; + } + + $userIdList = array_unique($userIdList); + + if (!$skipAclCheck) { + foreach ($userIdList as $i => $userId) { + $user = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->select(['id', 'type', 'isActive']) + ->where([ + 'id' => $userId, + 'isActive' => true, + ]) + ->findOne(); + + if (!$user) { + unset($userIdList[$i]); + continue; + } + + try { + $hasAccess = $this->aclManager->checkEntityStream($user, $entity); + } + catch (AclNotImplemented $e) { + $hasAccess = false; + } + + if (!$hasAccess) { + unset($userIdList[$i]); + } + } + + $userIdList = array_values($userIdList); + } + + if (empty($userIdList)) { + return; + } + + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Subscription') + ->where([ + 'userId' => $userIdList, + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + + $collection = new EntityCollection(); + + foreach ($userIdList as $userId) { + $subscription = $this->entityManager->getNewEntity('Subscription'); + + $subscription->set([ + 'userId' => $userId, + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + ]); + + $collection[] = $subscription; + } + + $this->entityManager->getMapper()->massInsert($collection); + } + + public function followEntity(Entity $entity, string $userId, bool $skipAclCheck = false): bool + { + if ($userId === self::SYSTEM_USER_ID) { + return false; + } + + if (!$this->metadata->get(['scopes', $entity->getEntityType(), 'stream'])) { + return false; + } + + if (!$skipAclCheck) { + $user = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->select(['id', 'type', 'isActive']) + ->where([ + 'id' => $userId, + 'isActive' => true, + ]) + ->findOne(); + + if (!$user) { + return false; + } + + $aclManager = $this->getUserAclManager($user); + + if (!$aclManager) { + return false; + } + + if (!$aclManager->check($user, $entity, Table::ACTION_STREAM)) { + return false; + } + } + + if ($this->checkIsFollowed($entity, $userId)) { + return true; + } + + $this->entityManager->createEntity('Subscription', [ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'userId' => $userId, + ]); + + return true; + } + + public function unfollowEntity(Entity $entity, string $userId): bool + { + if (!$this->metadata->get('scopes.' . $entity->getEntityType() . '.stream')) { + return false; + } + + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Subscription') + ->where([ + 'userId' => $userId, + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + + return true; + } + + public function unfollowAllUsersFromEntity(Entity $entity): void + { + if (!$entity->hasId()) { + return; + } + + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('Subscription') + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + /** + * @param array{ + * offset?: int|null, + * maxSize: int|null, + * skipOwn?: bool, + * where?: ?array, + * after?: ?string, + * filter?: ?string, + * } $params + * @throws NotFound + * @throws Forbidden + */ + public function findUserStream(string $userId, array $params): stdClass + { + $offset = intval($params['offset'] ?? 0); + $maxSize = intval($params['maxSize']); + + $sqLimit = $offset + $maxSize + 1; + + if ($userId === $this->user->getId()) { + $user = $this->user; + } + else { + /** @var ?User $user */ + $user = $this->entityManager->getEntity(User::ENTITY_TYPE, $userId); + + if (!$user) { + throw new NotFound(); + } + + if (!$this->acl->checkUserPermission($user, 'user')) { + throw new Forbidden(); + } + } + + $skipOwn = $params['skipOwn'] ?? false; + + $teamIdList = $user->getTeamIdList(); + + $select = [ + 'id', + 'number', + 'type', + 'post', + 'data', + 'parentType', + 'parentId', + 'relatedType', + 'relatedId', + 'targetType', + 'createdAt', + 'createdById', + 'createdByName', + 'isGlobal', + 'isInternal', + 'createdByGender', + ]; + + $onlyTeamEntityTypeList = $this->getOnlyTeamEntityTypeList($user); + $onlyOwnEntityTypeList = $this->getOnlyOwnEntityTypeList($user); + + $additionalQuery = null; + + if (!empty($params['where'])) { + $searchParams = SearchParams::fromRaw([ + 'where' => $params['where'], + ]); + + $additionalQuery = $this->selectBuilderFactory + ->create() + ->from(NoteEntity::ENTITY_TYPE) + ->withComplexExpressionsForbidden() + ->withWherePermissionCheck() + ->withSearchParams($searchParams) + ->buildQueryBuilder() + ->order([]) + ->build(); + } + + $queryList = []; + + $baseBuilder = $this->entityManager->getQueryBuilder()->select(); + + if ($additionalQuery) { + $baseBuilder->clone($additionalQuery); + } + else { + $baseBuilder->from(NoteEntity::ENTITY_TYPE); + } + + $baseBuilder + ->select($select) + ->order('number', 'DESC') + ->limit(0, $sqLimit) + ->where( + $this->getUserStreamWhereClause($params, $user) + ); + + $subscriptionBuilder = clone $baseBuilder; + + $subscriptionIgnoreWhereClause = $this->getUserStreamSubscriptionIgnoreWhereClause($user); + + $subscriptionBuilder + ->leftJoin('createdBy') + ->join( + 'Subscription', + 'subscription', + [ + 'entityType:' => 'parentType', + 'entityId:' => 'parentId', + 'subscription.userId' => $user->getId(), + ] + ) + ->where($subscriptionIgnoreWhereClause); + + if ($user->isPortal()) { + $subscriptionBuilder->where([ + 'isInternal' => false, + ]); + + $notAllEntityTypeList = $this->getNotAllEntityTypeList($user); + + $orGroup = [ + [ + 'relatedId' => null, + ], + [ + 'relatedId!=' => null, + 'relatedType!=' => $notAllEntityTypeList, + ], + ]; + + $aclManager = $this->getUserAclManager($user); + + if ($aclManager && $aclManager->check($user, Email::ENTITY_TYPE, Table::ACTION_READ)) { + $orGroup[] = [ + 'relatedId!=' => null, + 'relatedType' => Email::ENTITY_TYPE, + 'noteUser.userId' => $user->getId(), + ]; + + $subscriptionBuilder->leftJoin( + 'noteUser', + 'noteUser', [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + 'note.relatedType' => Email::ENTITY_TYPE, + ] + ); + } + + $subscriptionBuilder->where([ + 'OR' => $orGroup, + ]); + + $queryList[] = $subscriptionBuilder->build(); + } + + if (!$user->isPortal()) { + $subscriptionRestBuilder = clone $subscriptionBuilder; + + $subscriptionRestBuilder->where([ + 'OR' => [ + [ + 'relatedId!=' => null, + 'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), + ], + [ + 'relatedId=' => null, + ], + ], + ]); + + $queryList[] = $subscriptionRestBuilder->build(); + + if (count($onlyTeamEntityTypeList)) { + $subscriptionTeamBuilder = clone $subscriptionBuilder; + + $subscriptionTeamBuilder + ->distinct() + ->leftJoin( + 'noteTeam', + 'noteTeam', + [ + 'noteTeam.noteId=:' => 'id', + 'noteTeam.deleted' => false, + ] + ) + ->leftJoin( + 'noteUser', + 'noteUser', + [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + ] + ) + ->where([ + [ + 'relatedId!=' => null, + 'relatedType=' => $onlyTeamEntityTypeList, + ], + [ + 'OR' => [ + 'noteTeam.teamId' => $teamIdList, + 'noteUser.userId' => $user->getId(), + ], + ], + ]); + + $queryList[] = $subscriptionTeamBuilder->build(); + } + + if (count($onlyOwnEntityTypeList)) { + $subscriptionOwnBuilder = clone $subscriptionBuilder; + + $subscriptionOwnBuilder + ->distinct() + ->leftJoin( + 'noteUser', + 'noteUser', + [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + ] + ) + ->where([ + [ + 'relatedId!=' => null, + 'relatedType=' => $onlyOwnEntityTypeList, + ], + 'noteUser.userId' => $user->getId(), + ]); + + $queryList[] = $subscriptionOwnBuilder->build(); + } + } + + $subscriptionSuperBuilder = clone $baseBuilder; + + $subscriptionSuperBuilder + ->join( + 'Subscription', + 'subscription', + [ + 'entityType:' => 'superParentType', + 'entityId:' => 'superParentId', + 'subscription.userId' => $user->getId(), + ] + ) + ->leftJoin( + 'Subscription', + 'subscriptionExclude', + [ + 'entityType:' => 'parentType', + 'entityId:' => 'parentId', + 'subscription.userId' => $user->getId(), + ] + ) + ->where([ + 'OR' => [ + 'parentId!=:' => 'superParentId', + 'parentType!=:' => 'superParentType', + ], + 'subscriptionExclude.id' => null, + ]) + ->where($subscriptionIgnoreWhereClause); + + if (!$user->isPortal()) { + $subscriptionSuperRestBuilder = clone $subscriptionSuperBuilder; + + $subscriptionSuperRestBuilder->where([ + 'OR' => [ + [ + 'relatedId!=' => null, + 'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), + ], + [ + 'relatedId=' => null, + 'parentType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), + ], + ], + ]); + + $queryList[] = $subscriptionSuperRestBuilder->build(); + + if (count($onlyTeamEntityTypeList)) { + $subscriptionSuperTeamBuilder = clone $subscriptionSuperBuilder; + + $subscriptionSuperTeamBuilder + ->distinct() + ->leftJoin( + 'noteTeam', + 'noteTeam', + [ + 'noteTeam.noteId=:' => 'id', + 'noteTeam.deleted' => false, + ] + ) + ->leftJoin( + 'noteUser', + 'noteUser', + [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + ] + ) + ->where([ + 'OR' => [ + [ + 'relatedId!=' => null, + 'relatedType=' => $onlyTeamEntityTypeList, + ], + [ + 'relatedId=' => null, + 'parentType=' => $onlyTeamEntityTypeList, + ], + ], + [ + 'OR' => [ + 'noteTeam.teamId' => $teamIdList, + 'noteUser.userId' => $user->getId(), + ], + ] + ]); + + $queryList[] = $subscriptionSuperTeamBuilder->build(); + } + + if (count($onlyOwnEntityTypeList)) { + $subscriptionSuperOwnBuilder = clone $subscriptionSuperBuilder; + + $subscriptionSuperOwnBuilder + ->distinct() + ->leftJoin( + 'noteUser', + 'noteUser', + [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + ] + ) + ->where([ + [ + 'relatedId!=' => null, + 'relatedType=' => $onlyOwnEntityTypeList, + ], + 'noteUser.userId' => $user->getId(), + ]); + + $queryList[] = $subscriptionSuperOwnBuilder->build(); + } + } + + $queryList[] = (clone $baseBuilder) + ->leftJoin('users') + ->leftJoin('createdBy') + ->where([ + 'createdById!=' => $user->getId(), + 'usersMiddle.userId' => $user->getId(), + 'parentId' => null, + 'type' => NoteEntity::TYPE_POST, + 'isGlobal' => false, + ]) + ->build(); + + if ($user->isPortal()) { + $portalIdList = $user->getLinkMultipleIdList('portals'); + + if (!empty($portalIdList)) { + + $queryList[] = (clone $baseBuilder) + ->leftJoin('portals') + ->leftJoin('createdBy') + ->where([ + 'parentId' => null, + 'portalsMiddle.portalId' => $portalIdList, + 'type' => NoteEntity::TYPE_POST, + 'isGlobal' => false, + ]) + ->build(); + } + } + + if (!empty($teamIdList)) { + $queryList[] = (clone $baseBuilder) + ->leftJoin('teams') + ->leftJoin('createdBy') + ->where([ + 'parentId' => null, + 'teamsMiddle.teamId' => $teamIdList, + 'type' => NoteEntity::TYPE_POST, + 'isGlobal' => false, + ]) + ->build(); + } + + if ($skipOwn) { + foreach ($queryList as $i => $query) { + $queryList[$i] = $this->entityManager + ->getQueryBuilder() + ->select() + ->clone($query) + ->where([ + 'createdById!=' => $this->user->getId(), + ]) + ->build(); + } + } + + $queryList[] = (clone $baseBuilder) + ->leftJoin('createdBy') + ->where([ + 'createdById' => $user->getId(), + 'parentId' => null, + 'type' => NoteEntity::TYPE_POST, + 'isGlobal' => false, + ]) + ->build(); + + if ( + (!$user->isPortal() || $user->isAdmin()) && + !$user->isApi() + ) { + + $queryList[] = (clone $baseBuilder) + ->leftJoin('createdBy') + ->where([ + 'parentId' => null, + 'type' => NoteEntity::TYPE_POST, + 'isGlobal' => true, + ]) + ->build(); + } + + $builder = $this->entityManager + ->getQueryBuilder() + ->union() + ->all() + ->order('number', 'DESC') + ->limit($offset, $maxSize + 1); + + foreach ($queryList as $query) { + $builder->query($query); + } + + $unionQuery = $builder->build(); + + $sql = $this->entityManager + ->getQueryComposer() + ->compose($unionQuery); + + $sthCollection = $this->entityManager + ->getRDBRepository(NoteEntity::ENTITY_TYPE) + ->findBySql($sql); + + $collection = $this->entityManager + ->getCollectionFactory() + ->createFromSthCollection($sthCollection); + + foreach ($collection as $e) { + $this->loadNoteAdditionalFields($e); + + $this->applyAccessControlToNote($e, $user); + } + + $total = -2; + + if (count($collection) > $maxSize) { + $total = -1; + + unset($collection[count($collection) - 1]); + } + + return (object) [ + 'total' => $total, + 'collection' => $collection, + ]; + } + + /** + * @return array + */ + private function getUserStreamSubscriptionIgnoreWhereClause(User $user): array + { + $ignoreScopeList = $this->getIgnoreScopeList($user); + + if (empty($ignoreScopeList)) { + return []; + } + + $whereClause = []; + + $whereClause[] = [ + 'OR' => [ + 'relatedType' => null, + 'relatedType!=' => $ignoreScopeList, + ] + ]; + + $whereClause[] = [ + 'OR' => [ + 'parentType' => null, + 'parentType!=' => $ignoreScopeList, + ] + ]; + + if (in_array(Email::ENTITY_TYPE, $ignoreScopeList)) { + $whereClause[] = [ + 'type!=' => [ + NoteEntity::TYPE_EMAIL_RECEIVED, + NoteEntity::TYPE_EMAIL_SENT, + ], + ]; + } + + return $whereClause; + } + + /** + * @param array{ + * offset?: int|null, + * maxSize: int|null, + * skipOwn?: bool, + * where?: ?array, + * after?: ?string, + * filter?: ?string, + * } $params + * @return array + */ + private function getUserStreamWhereClause(array $params, User $user): array + { + $whereClause = []; + + if (!empty($params['after'])) { + $whereClause[]['createdAt>'] = $params['after']; + } + + if (!empty($params['filter'])) { + switch ($params['filter']) { + case 'posts': + $whereClause[]['type'] = NoteEntity::TYPE_POST; + + break; + + case 'updates': + $whereClause[]['type'] = [ + NoteEntity::TYPE_UPDATE, + NoteEntity::TYPE_STATUS, + ]; + + break; + } + } + + return $whereClause; + } + + private function loadNoteAdditionalFields(NoteEntity $note): void + { + $note->loadAdditionalFields(); + } + + /** + * @param array{ + * offset?: int|null, + * maxSize: int|null, + * skipOwn?: bool, + * where?: ?array, + * after?: ?string, + * filter?: ?string, + * } $params + * @throws NotFound + * @throws Forbidden + */ + public function find(string $scope, ?string $id, array $params): stdClass + { + if ($scope === User::ENTITY_TYPE) { + if (empty($id)) { + $id = $this->user->getId(); + } + + return $this->findUserStream($id, $params); + } + + $entity = $this->entityManager->getEntity($scope, $id); + + $onlyTeamEntityTypeList = $this->getOnlyTeamEntityTypeList($this->user); + $onlyOwnEntityTypeList = $this->getOnlyOwnEntityTypeList($this->user); + + if (empty($entity)) { + throw new NotFound(); + } + + if (!$this->acl->checkEntity($entity, Table::ACTION_STREAM)) { + throw new Forbidden(); + } + + $additionalQuery = null; + + if (!empty($params['where'])) { + $searchParams = SearchParams::fromRaw([ + 'where' => $params['where'], + ]); + + $additionalQuery = $this->selectBuilderFactory + ->create() + ->from(NoteEntity::ENTITY_TYPE) + ->withComplexExpressionsForbidden() + ->withWherePermissionCheck() + ->withSearchParams($searchParams) + ->buildQueryBuilder() + ->order([]) + ->build(); + } + + $builder = $this->entityManager->getQueryBuilder()->select(); + + if ($additionalQuery) { + $builder->clone($additionalQuery); + } + else { + $builder->from(NoteEntity::ENTITY_TYPE); + } + + $where = [ + 'OR' => [ + [ + 'parentType' => $scope, + 'parentId' => $id, + ], + [ + 'superParentType' => $scope, + 'superParentId' => $id, + ], + ] + ]; + + if ($this->user->isPortal()) { + $where = [ + 'parentType' => $scope, + 'parentId' => $id, + 'isInternal' => false, + ]; + + $notAllEntityTypeList = $this->getNotAllEntityTypeList($this->user); + + $orGroup = [ + [ + 'relatedId' => null, + ], + [ + 'relatedId!=' => null, + 'relatedType!=' => $notAllEntityTypeList, + ], + ]; + + if ($this->acl->check(Email::ENTITY_TYPE, Table::ACTION_READ)) { + $builder->leftJoin( + 'noteUser', + 'noteUser', + [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + 'note.relatedType' => Email::ENTITY_TYPE, + ] + ); + + $orGroup[] = [ + 'relatedId!=' => null, + 'relatedType' => Email::ENTITY_TYPE, + 'noteUser.userId' => $this->user->getId(), + ]; + } + + $where[] = [ + 'OR' => $orGroup, + ]; + } + + if (!$this->user->isPortal()) { + if (count($onlyTeamEntityTypeList) || count($onlyOwnEntityTypeList)) { + $builder + ->distinct() + ->leftJoin('teams') + ->leftJoin('users'); + + $where[] = [ + 'OR' => [ + 'OR' => [ + [ + 'relatedId!=' => null, + 'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), + ], + [ + 'relatedId=' => null, + 'superParentId' => $id, + 'superParentType' => $scope, + 'parentId!=' => null, + 'parentType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), + ], + [ + 'relatedId=' => null, + 'parentType=' => $scope, + 'parentId=' => $id, + ] + ], + [ + 'OR' => [ + [ + 'relatedId!=' => null, + 'relatedType=' => $onlyTeamEntityTypeList, + ], + [ + 'relatedId=' => null, + 'parentType=' => $onlyTeamEntityTypeList, + ] + ], + [ + 'OR' => [ + 'teamsMiddle.teamId' => $this->user->getTeamIdList(), + 'usersMiddle.userId' => $this->user->getId(), + ] + ] + ], + [ + 'OR' => [ + [ + 'relatedId!=' => null, + 'relatedType=' => $onlyOwnEntityTypeList, + ], + [ + 'relatedId=' => null, + 'parentType=' => $onlyOwnEntityTypeList, + ] + ], + 'usersMiddle.userId' => $this->user->getId(), + ] + ] + ]; + } + } + + if (!empty($params['filter'])) { + switch ($params['filter']) { + case 'posts': + $where['type'] = NoteEntity::TYPE_POST; + + break; + + case 'updates': + $where['type'] = [ + NoteEntity::TYPE_ASSIGN, + NoteEntity::TYPE_STATUS, + ]; + + break; + } + } + + $ignoreScopeList = $this->getIgnoreScopeList($this->user); + + if (!empty($ignoreScopeList)) { + $where[] = [ + 'OR' => [ + 'relatedType' => null, + 'relatedType!=' => $ignoreScopeList, + ] + ]; + + $where[] = [ + 'OR' => [ + 'parentType' => null, + 'parentType!=' => $ignoreScopeList, + ] + ]; + + if (in_array(Email::ENTITY_TYPE, $ignoreScopeList)) { + $where[] = [ + 'type!=' => [ + NoteEntity::TYPE_EMAIL_RECEIVED, + NoteEntity::TYPE_EMAIL_SENT, + ] + ]; + } + } + + $builder->where($where); + + if (!empty($params['after'])) { + $builder->where([ + 'createdAt>' => $params['after'], + ]); + } + + $countBuilder = clone $builder; + + $builder + ->limit($params['offset'] ?? 0, $params['maxSize']) + ->order('number', 'DESC'); + + /** @var iterable $collection */ + $collection = $this->entityManager + ->getRDBRepository(NoteEntity::ENTITY_TYPE) + ->clone($builder->build()) + ->find(); + + foreach ($collection as $e) { + if ( + $e->getType() === NoteEntity::TYPE_POST || + $e->getType() === NoteEntity::TYPE_EMAIL_RECEIVED + ) { + $e->loadAttachments(); + } + + if ( + $e->getParentId() && $e->getParentType() && + ($e->getParentId() !== $id || $e->getParentType() !== $scope) + ) { + $e->loadParentNameField('parent'); + } + + if ($e->getRelatedId() && $e->getRelatedType()) { + $e->loadParentNameField('related'); + } + + $this->applyAccessControlToNote($e); + } + + $count = $this->entityManager + ->getRDBRepository(NoteEntity::ENTITY_TYPE) + ->clone($countBuilder->build()) + ->count(); + + return (object) [ + 'total' => $count, + 'collection' => $collection, + ]; + } + + private function loadAssignedUserName(Entity $entity): void + { + $user = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->select(['name']) + ->where([ + 'id' => $entity->get('assignedUserId'), + ]) + ->findOne(); + + if ($user) { + $entity->set('assignedUserName', $user->get('name')); + } + } + + /** + * Notes having `related` or `superParent` are subjects to access control + * through `users` and `teams` fields. + * + * When users or teams of `related` or `parent` record are changed + * the note record will be changed too. + */ + private function processNoteTeamsUsers(NoteEntity $note, Entity $entity): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + $note->setAclIsProcessed(); + + $note->set('teamsIds', []); + $note->set('usersIds', []); + + if ($entity->hasLinkMultipleField('teams') && $entity->has('teamsIds')) { + $teamIdList = $entity->get('teamsIds'); + + if (!empty($teamIdList)) { + $note->set('teamsIds', $teamIdList); + } + } + + $ownerUserField = $this->aclManager->getReadOwnerUserField($entity->getEntityType()); + + if (!$ownerUserField) { + return; + } + + $defs = $this->entityManager->getDefs()->getEntity($entity->getEntityType()); + + if (!$defs->hasField($ownerUserField)) { + return; + } + + $fieldDefs = $defs->getField($ownerUserField); + + if ($fieldDefs->getType() === 'linkMultiple') { + $ownerUserIdAttribute = $ownerUserField . 'Ids'; + } + else if ($fieldDefs->getType() === 'link') { + $ownerUserIdAttribute = $ownerUserField . 'Id'; + } + else { + return; + } + + if (!$entity->has($ownerUserIdAttribute)) { + return; + } + + if ($fieldDefs->getType() === 'linkMultiple') { + $userIdList = $entity->getLinkMultipleIdList($ownerUserField); + } + else { + $userId = $entity->get($ownerUserIdAttribute); + + if (!$userId) { + return; + } + + $userIdList = [$userId]; + } + + $note->set('usersIds', $userIdList); + } + + public function noteEmailReceived(Entity $entity, Email $email, bool $isInitial = false): void + { + $entityType = $entity->getEntityType(); + + if ( + $this->entityManager + ->getRDBRepository(NoteEntity::ENTITY_TYPE) + ->where([ + 'type' => NoteEntity::TYPE_EMAIL_RECEIVED, + 'parentId' => $entity->getId(), + 'parentType' => $entityType, + 'relatedId' => $email->getId(), + 'relatedType' => Email::ENTITY_TYPE, + ]) + ->findOne() + ) { + return; + } + + /** @var NoteEntity $note */ + $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); + + $note->set('type', NoteEntity::TYPE_EMAIL_RECEIVED); + $note->set('parentId', $entity->getId()); + $note->set('parentType', $entityType); + $note->set('relatedId', $email->getId()); + $note->set('relatedType', Email::ENTITY_TYPE); + + $this->processNoteTeamsUsers($note, $email); + + if ($email->get('accountId')) { + $note->set('superParentId', $email->get('accountId')); + $note->set('superParentType', 'Account'); + } + + $withContent = in_array($entityType, $this->config->get('streamEmailWithContentEntityTypeList', [])); + + if ($withContent) { + $note->set('post', $email->getBodyPlain()); + } + + $data = []; + + $data['emailId'] = $email->getId(); + $data['emailName'] = $email->get('name'); + $data['isInitial'] = $isInitial; + + if ($withContent) { + $data['attachmentsIds'] = $email->get('attachmentsIds'); + } + + $from = $email->get('from'); + + if ($from) { + $person = $this->getEmailAddressRepository()->getEntityByAddress($from); + + if ($person) { + $data['personEntityType'] = $person->getEntityType(); + $data['personEntityName'] = $person->get('name'); + $data['personEntityId'] = $person->getId(); + } + } + + $note->set('data', (object) $data); + + $this->entityManager->saveEntity($note); + } + + public function noteEmailSent(Entity $entity, Email $email): void + { + $entityType = $entity->getEntityType(); + + /** @var NoteEntity $note */ + $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); + + $note->set('type', NoteEntity::TYPE_EMAIL_SENT); + $note->set('parentId', $entity->getId()); + $note->set('parentType', $entityType); + $note->set('relatedId', $email->getId()); + $note->set('relatedType', Email::ENTITY_TYPE); + + $this->processNoteTeamsUsers($note, $email); + + if ($email->get('accountId')) { + $note->set('superParentId', $email->get('accountId')); + $note->set('superParentType', 'Account'); + } + + $withContent = in_array($entityType, $this->config->get('streamEmailWithContentEntityTypeList', [])); + + if ($withContent) { + $note->set('post', $email->getBodyPlain()); + } + + $data = []; + + $data['emailId'] = $email->getId(); + $data['emailName'] = $email->get('name'); + + if ($withContent) { + $data['attachmentsIds'] = $email->get('attachmentsIds'); + } + + $user = $this->user; + + $person = null; + + if (!$user->isSystem()) { + $person = $user; + } + else { + $from = $email->get('from'); + + if ($from) { + $person = $this->getEmailAddressRepository()->getEntityByAddress($from); + } + } + + if ($person) { + $data['personEntityType'] = $person->getEntityType(); + $data['personEntityName'] = $person->get('name'); + $data['personEntityId'] = $person->getId(); + } + + $note->set('data', (object) $data); + + $this->entityManager->saveEntity($note); + } + + /** + * @param array $options + */ + public function noteCreate(Entity $entity, array $options = []): void + { + $entityType = $entity->getEntityType(); + + /** @var NoteEntity $note */ + $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); + + $note->set('type', NoteEntity::TYPE_CREATE); + $note->set('parentId', $entity->getId()); + $note->set('parentType', $entityType); + + if ($entity->has('accountId') && $entity->get('accountId')) { + $note->set('superParentId', $entity->get('accountId')); + $note->set('superParentType', 'Account'); + + // only if has super parent + $this->processNoteTeamsUsers($note, $entity); + } + + $data = []; + + if ($entity->get('assignedUserId')) { + if (!$entity->has('assignedUserName')) { + $this->loadAssignedUserName($entity); + } + + $data['assignedUserId'] = $entity->get('assignedUserId'); + $data['assignedUserName'] = $entity->get('assignedUserName'); + } + + $statusFields = $this->getStatusFields(); + + if (!empty($statusFields[$entityType])) { + $field = $statusFields[$entityType]; + $value = $entity->get($field); + + if (!empty($value)) { + $data['statusValue'] = $value; + $data['statusField'] = $field; + $data['statusStyle'] = $this->getStatusStyle($entityType, $field, $value); + } + } + + $note->set('data', (object) $data); + + $o = []; + + if (!empty($options['createdById'])) { + $o['createdById'] = $options['createdById']; + } + + $this->entityManager->saveEntity($note, $o); + } + + /** + * @param mixed $value + */ + private function getStatusStyle(string $entityType, string $field, $value): string + { + $style = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'style', $value]); + + if ($style) { + return $style; + } + + $statusStyles = $this->getStatusStyles(); + + if (isset($statusStyles[$entityType]) && isset($statusStyles[$entityType][$value])) { + return (string) $statusStyles[$entityType][$value]; + } + + if (in_array($value, $this->successDefaultStyleList)) { + return 'success'; + } + + if (in_array($value, $this->dangerDefaultStyleList)) { + return 'danger'; + } + + return 'default'; + } + + /** + * @param array $options + */ + public function noteCreateRelated( + Entity $entity, + string $parentType, + string $parentId, + array $options = [] + ): void { + + /** @var NoteEntity $note */ + $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); + + $entityType = $entity->getEntityType(); + + $note->set('type', NoteEntity::TYPE_CREATE_RELATED); + $note->set('parentId', $parentId); + $note->set('parentType', $parentType); + $note->set([ + 'relatedType' => $entityType, + 'relatedId' => $entity->getId(), + ]); + + $this->processNoteTeamsUsers($note, $entity); + + if ($entity->has('accountId') && $entity->get('accountId')) { + $note->set('superParentId', $entity->get('accountId')); + $note->set('superParentType', 'Account'); + } + + $o = []; + + if (!empty($options['createdById'])) { + $o['createdById'] = $options['createdById']; + } + + $this->entityManager->saveEntity($note, $o); + } + + /** + * @param array $options + */ + public function noteRelate(Entity $entity, string $parentType, string $parentId, array $options = []): void + { + $entityType = $entity->getEntityType(); + + $existing = $this->entityManager + ->getRDBRepository(NoteEntity::ENTITY_TYPE) + ->select(['id']) + ->where([ + 'type' => NoteEntity::TYPE_RELATE, + 'parentId' => $parentId, + 'parentType' => $parentType, + 'relatedId' => $entity->getId(), + 'relatedType' => $entityType, + ]) + ->findOne(); + + if ($existing) { + return; + } + + /** @var NoteEntity $note */ + $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); + + $note->set([ + 'type' => NoteEntity::TYPE_RELATE, + 'parentId' => $parentId, + 'parentType' => $parentType, + 'relatedType' => $entityType, + 'relatedId' => $entity->getId(), + ]); + + $this->processNoteTeamsUsers($note, $entity); + + $o = []; + + if (!empty($options['createdById'])) { + $o['createdById'] = $options['createdById']; + } + + $this->entityManager->saveEntity($note, $o); + } + + /** + * @param array $options + */ + public function noteAssign(Entity $entity, array $options = []): void + { + /** @var NoteEntity $note */ + $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); + + $note->set('type', NoteEntity::TYPE_ASSIGN); + $note->set('parentId', $entity->getId()); + $note->set('parentType', $entity->getEntityType()); + + if ($entity->has('accountId') && $entity->get('accountId')) { + $note->set('superParentId', $entity->get('accountId')); + $note->set('superParentType', Account::ENTITY_TYPE); + + // only if has super parent + $this->processNoteTeamsUsers($note, $entity); + } + + if ($entity->get('assignedUserId')) { + if (!$entity->has('assignedUserName')) { + $this->loadAssignedUserName($entity); + } + + $note->set('data', [ + 'assignedUserId' => $entity->get('assignedUserId'), + 'assignedUserName' => $entity->get('assignedUserName'), + ]); + } else { + $note->set('data', [ + 'assignedUserId' => null + ]); + } + + $o = []; + + if (!empty($options['createdById'])) { + $o['createdById'] = $options['createdById']; + } + + if (!empty($options['modifiedById'])) { + $o['createdById'] = $options['modifiedById']; + } + + $this->entityManager->saveEntity($note, $o); + } + + /** + * @param array $options + */ + public function noteStatus(Entity $entity, string $field, array $options = []): void + { + /** @var NoteEntity $note */ + $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); + + $note->set('type', NoteEntity::TYPE_STATUS); + $note->set('parentId', $entity->getId()); + $note->set('parentType', $entity->getEntityType()); + + if ($entity->has('accountId') && $entity->get('accountId')) { + $note->set('superParentId', $entity->get('accountId')); + $note->set('superParentType', Account::ENTITY_TYPE); + + $this->processNoteTeamsUsers($note, $entity); + } + + $entityType = $entity->getEntityType(); + + $value = $entity->get($field); + + $style = $this->getStatusStyle($entityType, $field, $value); + + $note->set('data', [ + 'field' => $field, + 'value' => $value, + 'style' => $style, + ]); + + $o = []; + + if (!empty($options['createdById'])) { + $o['createdById'] = $options['createdById']; + } + + if (!empty($options['modifiedById'])) { + $o['createdById'] = $options['modifiedById']; + } + + $this->entityManager->saveEntity($note, $o); + } + + /** + * @return array< + * string, + * array{ + * actualList: string[], + * notActualList: string[], + * fieldType: string, + * } + * > + */ + private function getAuditedFieldsData(Entity $entity): array + { + $entityType = $entity->getEntityType(); + + $statusFields = $this->getStatusFields(); + + if (array_key_exists($entityType, $this->auditedFieldsCache)) { + return $this->auditedFieldsCache[$entityType]; + } + + /** @var array> $fields */ + $fields = $this->metadata->get('entityDefs.' . $entityType . '.fields'); + + $auditedFields = []; + + foreach ($fields as $field => $defs) { + if (empty($defs['audited'])) { + continue; + } + + if (!empty($statusFields[$entityType]) && $statusFields[$entityType] === $field) { + continue; + } + + /** @var ?string $type */ + $type = $defs['type'] ?? null; + + if (!$type) { + continue; + } + + $auditedFields[$field] = []; + + $auditedFields[$field]['actualList'] = + $this->fieldUtil->getActualAttributeList($entityType, $field); + + $auditedFields[$field]['notActualList'] = + $this->fieldUtil->getNotActualAttributeList($entityType, $field); + + $auditedFields[$field]['fieldType'] = $type; + } + + $this->auditedFieldsCache[$entityType] = $auditedFields; + + return $this->auditedFieldsCache[$entityType]; + } + + /** + * @param array $options + */ + public function handleAudited(Entity $entity, array $options = []): void + { + $auditedFields = $this->getAuditedFieldsData($entity); + + $updatedFieldList = []; + + $was = []; + $became = []; + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()); + + foreach ($auditedFields as $field => $item) { + $updated = false; + + foreach ($item['actualList'] as $attribute) { + if ($entity->hasFetched($attribute) && $entity->isAttributeChanged($attribute)) { + $updated = true; + + break; + } + } + + if (!$updated) { + continue; + } + + $updatedFieldList[] = $field; + + $fieldDefs = $entityDefs->hasField($field) ? $entityDefs->getField($field) : null; + + if ( + $fieldDefs && + in_array($fieldDefs->getType(), ['text', 'wysiwyg']) + ) { + continue; + } + + foreach ($item['actualList'] as $attribute) { + $was[$attribute] = $entity->getFetched($attribute); + $became[$attribute] = $entity->get($attribute); + } + + foreach ($item['notActualList'] as $attribute) { + $was[$attribute] = $entity->getFetched($attribute); + $became[$attribute] = $entity->get($attribute); + } + + if ($item['fieldType'] === 'linkParent') { + $wasParentType = $was[$field . 'Type']; + $wasParentId = $was[$field . 'Id']; + + if ($wasParentType && $wasParentId && $this->entityManager->hasRepository($wasParentType)) { + $wasParent = $this->entityManager->getEntity($wasParentType, $wasParentId); + + if ($wasParent) { + $was[$field . 'Name'] = $wasParent->get('name'); + } + } + } + } + + if (count($updatedFieldList) === 0) { + return; + } + + /** @var NoteEntity $note */ + $note = $this->entityManager->getNewEntity(NoteEntity::ENTITY_TYPE); + + $note->set('type', NoteEntity::TYPE_UPDATE); + $note->set('parentId', $entity->getId()); + $note->set('parentType', $entity->getEntityType()); + + $note->set('data', [ + 'fields' => $updatedFieldList, + 'attributes' => [ + 'was' => (object) $was, + 'became' => (object) $became, + ], + ]); + + $o = []; + + if (!empty($options['modifiedById'])) { + $o['createdById'] = $options['modifiedById']; + } + + $this->entityManager->saveEntity($note, $o); + } + + /** + * @return string[] + * @deprecated Use `getEntityFollowerIdList`. + */ + public function getEntityFolowerIdList(Entity $entity): array + { + return $this->getEntityFollowerIdList($entity); + } + + /** + * @return string[] + * @internal Must be left for backward compatibility. + */ + public function getEntityFollowerIdList(Entity $entity): array + { + $userList = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->select(['id']) + ->join('Subscription', 'subscription', [ + 'subscription.userId=:' => 'user.id', + 'subscription.entityId' => $entity->getId(), + 'subscription.entityType' => $entity->getEntityType(), + ]) + ->where(['isActive' => true]) + ->find(); + + $idList = []; + + foreach ($userList as $user) { + $idList[] = $user->getId(); + } + + return $idList; + } + + /** + * @return RecordCollection + */ + public function findEntityFollowers(Entity $entity, SearchParams $searchParams): RecordCollection + { + $builder = $this->selectBuilderFactory + ->create() + ->from(User::ENTITY_TYPE) + ->withSearchParams($searchParams) + ->withStrictAccessControl() + ->buildQueryBuilder(); + + if (!$searchParams->getOrderBy()) { + $builder->order('LIST:id:' . $this->user->getId(), 'DESC'); + $builder->order('name'); + } + + $builder->join( + 'Subscription', + 'subscription', + [ + 'subscription.userId=:' => 'user.id', + 'subscription.entityId' => $entity->getId(), + 'subscription.entityType' => $entity->getEntityType(), + ] + ); + + $query = $builder->build(); + + /** @var \Espo\ORM\Collection $collection */ + $collection = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->clone($query) + ->find(); + + $total = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->clone($query) + ->count(); + + $userService = $this->recordServiceContainer->get(User::ENTITY_TYPE); + + foreach ($collection as $e) { + $userService->prepareEntityForOutput($e); + } + + /** @var RecordCollection */ + return new RecordCollection($collection, $total); + } + + /** + * @return array{ + * idList: string[], + * nameMap: stdClass, + * } + */ + public function getEntityFollowers(Entity $entity, int $offset = 0, ?int $limit = null): array + { + if (!$limit) { + $limit = 200; + } + + $userList = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->select(['id', 'name']) + ->join( + 'Subscription', + 'subscription', + [ + 'subscription.userId=:' => 'user.id', + 'subscription.entityId' => $entity->getId(), + 'subscription.entityType' => $entity->getEntityType() + ] + ) + ->limit($offset, $limit) + ->where([ + 'isActive' => true, + ]) + ->order([ + ['LIST:id:' . $this->user->getId(), 'DESC'], + ['name'], + ]) + ->find(); + + $data = [ + 'idList' => [], + 'nameMap' => (object) [], + ]; + + foreach ($userList as $user) { + /** @var string $id */ + $id = $user->getId(); + + $data['idList'][] = $id; + $data['nameMap']->$id = $user->get('name'); + } + + return $data; + } + + /** + * @return string[] + */ + private function getOnlyTeamEntityTypeList(User $user): array + { + if ($user->isPortal()) { + return []; + } + + $list = []; + + $scopes = $this->metadata->get('scopes', []); + + foreach ($scopes as $scope => $item) { + if ($scope === User::ENTITY_TYPE) { + continue; + } + + if (empty($item['entity'])) { + continue; + } + + if (empty($item['object'])) { + continue; + } + + if ( + $this->aclManager->checkReadOnlyTeam($user, $scope) + ) { + $list[] = $scope; + } + } + + return $list; + } + + /** + * @return string[] + */ + private function getOnlyOwnEntityTypeList(User $user): array + { + if ($user->isPortal()) { + return []; + } + + $list = []; + + $scopes = $this->metadata->get('scopes', []); + + foreach ($scopes as $scope => $item) { + if ($scope === User::ENTITY_TYPE) { + continue; + } + + if (empty($item['entity'])) { + continue; + } + + if (empty($item['object'])) { + continue; + } + + if ( + $this->aclManager->checkReadOnlyOwn($user, $scope) + ) { + $list[] = $scope; + } + } + + return $list; + } + + private function getUserAclManager(User $user): ?AclManager + { + try { + return $this->userAclManagerProvider->get($user); + } + catch (Error $e) { + return null; + } + } + + /** + * @return string[] + */ + private function getNotAllEntityTypeList(User $user): array + { + if (!$user->isPortal()) { + return []; + } + + $aclManager = $this->getUserAclManager($user); + + $list = []; + + $scopes = $this->metadata->get('scopes', []); + + foreach ($scopes as $scope => $item) { + if ($scope === User::ENTITY_TYPE) { + continue; + } + + if (empty($item['entity'])) { + continue; + } + + if (empty($item['object'])) { + continue; + } + + if ( + !$aclManager || + $aclManager->getLevel($user, $scope, Table::ACTION_READ) !== Table::LEVEL_ALL + ) { + $list[] = $scope; + } + } + + return $list; + } + + /** + * @return string[] + */ + private function getIgnoreScopeList(User $user): array + { + $ignoreScopeList = []; + + $scopes = $this->metadata->get('scopes', []); + + $aclManager = $this->getUserAclManager($user); + + foreach ($scopes as $scope => $item) { + if (empty($item['entity'])) { + continue; + } + + if (empty($item['object'])) { + continue; + } + + try { + $hasAccess = + $aclManager && + $aclManager->checkScope($user, $scope, Table::ACTION_READ) && + $aclManager->checkScope($user, $scope, Table::ACTION_STREAM); + } + catch (AclNotImplemented $e) { + $hasAccess = false; + } + + if (!$hasAccess) { + $ignoreScopeList[] = $scope; + } + } + + return $ignoreScopeList; + } + + /** + * @return Collection + */ + public function getSubscriberList(string $parentType, string $parentId, bool $isInternal = false): Collection + { + if (!$this->metadata->get(['scopes', $parentType, 'stream'])) { + /** @var Collection */ + return $this->entityManager->getCollectionFactory()->create(User::ENTITY_TYPE, []); + } + + $builder = $this->entityManager + ->getQueryBuilder() + ->select() + ->from('Subscription') + ->select('userId') + ->where([ + 'entityId' => $parentId, + 'entityType' => $parentType, + ]); + + if ($isInternal) { + $builder + ->join(User::ENTITY_TYPE, 'user', ['user.id:' => 'userId']) + ->where([ + 'user.type!=' => User::TYPE_PORTAL, + ]); + } + + $subQuery = $builder->build(); + + /** @var Collection */ + return $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->where([ + 'isActive' => true, + 'id=s' => $subQuery->getRaw(), + ]) + ->select(['id', 'type']) + ->find(); + } + + /** + * Changes users and teams of notes related to an entity according users and teams of the entity. + * + * Notes having `related` or `superParent` are subjects to access control + * through `users` and `teams` fields. + * + * When users or teams of `related` or `parent` record are changed + * the note record will be changed too. + */ + public function processNoteAcl(Entity $entity, bool $forceProcessNoteNotifications = false): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + $entityType = $entity->getEntityType(); + + if (in_array($entityType, ['Note', 'User', 'Team', 'Role', 'Portal', 'PortalRole'])) { + return; + } + + if (!$this->metadata->get(['scopes', $entityType, 'acl'])) { + return; + } + + if (!$this->metadata->get(['scopes', $entityType, 'object'])) { + return; + } + + $usersAttributeIsChanged = false; + $teamsAttributeIsChanged = false; + + $ownerUserField = $this->aclManager->getReadOwnerUserField($entityType); + + /* @var \Espo\ORM\Defs\EntityDefs $defs */ + $defs = $this->entityManager->getDefs()->getEntity($entity->getEntityType()); + + $userIdList = []; + $teamIdList = []; + + if ($ownerUserField) { + if (!$defs->hasField($ownerUserField)) { + throw new LogicException("Non-existing read-owner user field."); + } + + $fieldDefs = $defs->getField($ownerUserField); + + if ($fieldDefs->getType() === 'linkMultiple') { + $ownerUserIdAttribute = $ownerUserField . 'Ids'; + } + else if ($fieldDefs->getType() === 'link') { + $ownerUserIdAttribute = $ownerUserField . 'Id'; + } + else { + throw new LogicException("Bad read-owner user field type."); + } + + if ($entity->isAttributeChanged($ownerUserIdAttribute)) { + $usersAttributeIsChanged = true; + } + + if ($usersAttributeIsChanged || $forceProcessNoteNotifications) { + if ($fieldDefs->getType() === 'linkMultiple') { + $userIdList = $entity->getLinkMultipleIdList($ownerUserField) ?? []; + } + else { + $userId = $entity->get($ownerUserIdAttribute); + + $userIdList = $userId ? [$userId] : []; + } + } + } + + if ($entity->hasLinkMultipleField('teams')) { + if ($entity->isAttributeChanged('teamsIds')) { + $teamsAttributeIsChanged = true; + } + + if ($teamsAttributeIsChanged || $forceProcessNoteNotifications) { + $teamIdList = $entity->getLinkMultipleIdList('teams') ?? []; + } + } + + if (!$usersAttributeIsChanged && !$teamsAttributeIsChanged && !$forceProcessNoteNotifications) { + return; + } + + $limit = $this->config->get('noteAclLimit', self::NOTE_ACL_LIMIT); + + $noteList = $this->entityManager + ->getRDBRepository(NoteEntity::ENTITY_TYPE) + ->where([ + 'OR' => [ + [ + 'relatedId' => $entity->getId(), + 'relatedType' => $entityType, + ], + [ + 'parentId' => $entity->getId(), + 'parentType' => $entityType, + 'superParentId!=' => null, + 'relatedId' => null, + ] + ] + ]) + ->select([ + 'id', + 'parentType', + 'parentId', + 'superParentType', + 'superParentId', + 'isInternal', + 'relatedType', + 'relatedId', + 'createdAt', + ]) + ->order('number', 'DESC') + ->limit(0, $limit) + ->find(); + + $notificationPeriod = '-' . $this->config->get('noteNotificationPeriod', self::NOTE_NOTIFICATION_PERIOD); + $aclPeriod = '-' . $this->config->get('noteAclPeriod', self::NOTE_ACL_PERIOD); + + $notificationThreshold = (new DateTime())->modify($notificationPeriod); + $aclThreshold = (new DateTime())->modify($aclPeriod); + + foreach ($noteList as $note) { + $this->processNoteAclItem($entity, $note, [ + 'teamsAttributeIsChanged' => $teamsAttributeIsChanged, + 'usersAttributeIsChanged' => $usersAttributeIsChanged, + 'forceProcessNoteNotifications' => $forceProcessNoteNotifications, + 'teamIdList' => $teamIdList, + 'userIdList' => $userIdList, + 'notificationThreshold' => $notificationThreshold, + 'aclThreshold' => $aclThreshold, + ]); + } + } + + /** + * @param array{ + * teamsAttributeIsChanged: bool, + * usersAttributeIsChanged: bool, + * forceProcessNoteNotifications: bool, + * teamIdList: string[], + * userIdList: string[], + * notificationThreshold: \DateTimeInterface, + * aclThreshold: \DateTimeInterface, + * } $params + * @return void + */ + private function processNoteAclItem(Entity $entity, NoteEntity $note, array $params): void + { + $teamsAttributeIsChanged = $params['teamsAttributeIsChanged']; + $usersAttributeIsChanged = $params['usersAttributeIsChanged']; + $forceProcessNoteNotifications = $params['forceProcessNoteNotifications']; + + $teamIdList = $params['teamIdList']; + $userIdList = $params['userIdList']; + + $notificationThreshold = $params['notificationThreshold']; + $aclThreshold = $params['aclThreshold']; + + $createdAt = $note->getCreatedAt(); + + if (!$createdAt) { + return; + } + + if (!$entity->isNew()) { + if ($createdAt->getTimestamp() < $notificationThreshold->getTimestamp()) { + $forceProcessNoteNotifications = false; + } + + if ($createdAt->getTimestamp() < $aclThreshold->getTimestamp()) { + return; + } + } + + if ($teamsAttributeIsChanged || $forceProcessNoteNotifications) { + $note->set('teamsIds', $teamIdList); + } + + if ($usersAttributeIsChanged || $forceProcessNoteNotifications) { + $note->set('usersIds', $userIdList); + } + + $this->entityManager->saveEntity($note, [ + 'forceProcessNotifications' => $forceProcessNoteNotifications, + ]); + } + + public function applyAccessControlToNote(NoteEntity $note, ?User $user = null): void + { + if (!$user) { + $user = $this->user; + } + + $this->noteAccessControl->apply($note, $user); + } + + private function getEmailAddressRepository(): EmailAddressRepository + { + /** @var EmailAddressRepository */ + return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + } +} diff --git a/tests/integration/Espo/Note/AclTest.php b/tests/integration/Espo/Note/AclTest.php index da759294fe..cb52290797 100644 --- a/tests/integration/Espo/Note/AclTest.php +++ b/tests/integration/Espo/Note/AclTest.php @@ -30,7 +30,7 @@ namespace tests\integration\Espo\Note; use Espo\{ - Services\Stream as StreamService, + Tools\Stream\Service as StreamService, ORM\EntityManager, }; @@ -42,7 +42,7 @@ class AclTest extends \tests\integration\Core\BaseTestCase $em = $this->getContainer()->get('entityManager'); /* @var $streamService StreamService*/ - $streamService = $this->getContainer()->get('serviceFactory')->create('Stream'); + $streamService = $this->getContainer()->get('injectableFactory')->create(StreamService::class); $team1 = $em->createEntity('Team', [ 'name' => 'team-1', diff --git a/tests/unit/Espo/Core/Mail/ImporterTest.php b/tests/unit/Espo/Core/Mail/ImporterTest.php index f3ce094b7b..081536de28 100644 --- a/tests/unit/Espo/Core/Mail/ImporterTest.php +++ b/tests/unit/Espo/Core/Mail/ImporterTest.php @@ -37,6 +37,7 @@ use Espo\Core\Notification\AssignmentNotificator; use Espo\ORM\Value\ValueAccessor; use Espo\ORM\Value\ValueAccessorFactory; use Espo\Core\{ + Job\JobSchedulerFactory, Mail\Importer, Mail\Importer\Data as ImporterData, Mail\MessageWrapper, @@ -48,8 +49,7 @@ use Espo\Core\{ Repositories\Database, Utils\Metadata, Notification\AssignmentNotificatorFactory, - FieldProcessing\Relation\LinkMultipleSaver, -}; + FieldProcessing\Relation\LinkMultipleSaver}; use Espo\ORM\Repository\RDBSelectBuilder; @@ -142,6 +142,8 @@ class ImporterTest extends \PHPUnit\Framework\TestCase $this->duplicateFinder = $this->createMock(Importer\DuplicateFinder::class); + + $this->jobSchedulerFactory = $this->createMock(JobSchedulerFactory::class); } function testImport1() @@ -183,7 +185,8 @@ class ImporterTest extends \PHPUnit\Framework\TestCase $this->assignmentNotificatorFactory, $this->parserFactory, $this->linkMultipleSaver, - $this->duplicateFinder + $this->duplicateFinder, + $this->jobSchedulerFactory ); $message = new MessageWrapper(0, null, null, $contents);