diff --git a/application/Espo/Controllers/Stream.php b/application/Espo/Controllers/Stream.php index eeac981397..e4d35e23a1 100644 --- a/application/Espo/Controllers/Stream.php +++ b/application/Espo/Controllers/Stream.php @@ -41,22 +41,18 @@ use Espo\Core\Select\Where\Item as WhereItem; use Espo\Entities\User as UserEntity; use Espo\Tools\Stream\RecordService; +use Espo\Tools\Stream\UserRecordService; use stdClass; class Stream { public static string $defaultAction = 'list'; - private RecordService $service; - private SearchParamsFetcher $searchParamsFetcher; - public function __construct( - RecordService $service, - SearchParamsFetcher $searchParamsFetcher - ) { - $this->service = $service; - $this->searchParamsFetcher = $searchParamsFetcher; - } + private RecordService $service, + private UserRecordService $userRecordService, + private SearchParamsFetcher $searchParamsFetcher + ) {} /** * @throws BadRequest @@ -79,7 +75,7 @@ class Stream $searchParams = $this->fetchSearchParams($request); $result = $scope === UserEntity::ENTITY_TYPE ? - $this->service->findUser($id, $searchParams) : + $this->userRecordService->find($id, $searchParams) : $this->service->find($scope, $id ?? '', $searchParams); return (object) [ @@ -110,7 +106,7 @@ class Stream ->withPrimaryFilter('posts'); $result = $scope === UserEntity::ENTITY_TYPE ? - $this->service->findUser($id, $searchParams) : + $this->userRecordService->find($id, $searchParams) : $this->service->find($scope, $id ?? '', $searchParams); return (object) [ diff --git a/application/Espo/Tools/Stream/RecordService.php b/application/Espo/Tools/Stream/RecordService.php index b0ed4e5ac5..3931e10ffc 100644 --- a/application/Espo/Tools/Stream/RecordService.php +++ b/application/Espo/Tools/Stream/RecordService.php @@ -34,175 +34,25 @@ use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; use Espo\Core\Select\SearchParams; use Espo\ORM\EntityManager; -use Espo\Entities\Subscription; use Espo\Entities\User; use Espo\Entities\Note; use Espo\Entities\Email; -use Espo\Core\Utils\Metadata; use Espo\Core\Acl; -use Espo\Core\AclManager; use Espo\Core\Acl\Table; -use Espo\Core\Acl\Exceptions\NotImplemented as AclNotImplemented; use Espo\Core\Record\Collection as RecordCollection; -use Espo\Core\Select\SelectBuilderFactory; -use Espo\Core\Utils\Acl\UserAclManagerProvider; -use Espo\ORM\Query\Part\Order; -use Espo\ORM\Query\Select; use Espo\ORM\Query\SelectBuilder as SelectQueryBuilder; +use Espo\Tools\Stream\RecordService\Helper; class RecordService { public function __construct( private EntityManager $entityManager, private User $user, - private Metadata $metadata, private Acl $acl, - private AclManager $aclManager, - private SelectBuilderFactory $selectBuilderFactory, - private UserAclManagerProvider $userAclManagerProvider, - private NoteAccessControl $noteAccessControl + private NoteAccessControl $noteAccessControl, + private Helper $helper ) {} - /** - * Find user stream records. - * - * @return RecordCollection - * @throws Forbidden - * @throws BadRequest - * @throws NotFound - */ - public function findUser(?string $userId, SearchParams $searchParams): RecordCollection - { - $userId ??= $this->user->getId(); - - $offset = $searchParams->getOffset() ?? 0; - $maxSize = $searchParams->getMaxSize(); - - $sqLimit = $offset + $maxSize + 1; - - $user = $userId === $this->user->getId() ? - $this->user : - $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); - - if (!$user) { - throw new NotFound("User not found."); - } - - /** @noinspection PhpRedundantOptionalArgumentInspection */ - if (!$this->acl->checkUserPermission($user, 'user')) { - throw new Forbidden("No user permission access."); - } - - $queryList = []; - - $baseBuilder = $this->buildBaseQueryBuilder($searchParams) - ->select([ - 'id', - 'number', - 'type', - 'post', - 'data', - 'parentType', - 'parentId', - 'relatedType', - 'relatedId', - 'targetType', - 'createdAt', - 'createdById', - 'createdByName', - 'isGlobal', - 'isInternal', - 'createdByGender', - ]) - ->order('number', Order::DESC) - ->limit(0, $sqLimit); - - $this->buildSubscriptionQueries($user, $baseBuilder, $queryList); - $this->buildSubscriptionSuperQuery($user, $baseBuilder, $queryList); - $this->buildPostedToUserQuery($user, $baseBuilder, $queryList); - $this->buildPostedToPortalQuery($user, $baseBuilder, $queryList); - $this->buildPostedToTeamsQuery($user, $baseBuilder, $queryList); - $this->buildPostedByUserQuery($user, $baseBuilder, $queryList); - $this->buildPostedToGlobalQuery($user, $baseBuilder, $queryList); - - $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 - ->getRDBRepositoryByClass(Note::class) - ->findBySql($sql); - - $collection = $this->entityManager - ->getCollectionFactory() - ->createFromSthCollection($sthCollection); - - foreach ($collection as $e) { - $this->loadNoteAdditionalFields($e); - $this->applyAccessControlToNote($e, $user); - } - - return RecordCollection::createNoCount($collection, $maxSize); - } - - /** - * @return array - */ - private function getSubscriptionIgnoreWhereClause(User $user): array - { - $ignoreScopeList = $this->getIgnoreScopeList($user, true); - $ignoreRelatedScopeList = $this->getIgnoreScopeList($user); - - if (empty($ignoreScopeList)) { - return []; - } - - $whereClause = []; - - $whereClause[] = [ - 'OR' => [ - 'relatedType' => null, - 'relatedType!=' => $ignoreRelatedScopeList, - ] - ]; - - $whereClause[] = [ - 'OR' => [ - 'parentType' => null, - 'parentType!=' => $ignoreScopeList, - ] - ]; - - if (in_array(Email::ENTITY_TYPE, $ignoreRelatedScopeList)) { - $whereClause[] = [ - 'type!=' => [ - Note::TYPE_EMAIL_RECEIVED, - Note::TYPE_EMAIL_SENT, - ], - ]; - } - - return $whereClause; - } - - private function loadNoteAdditionalFields(Note $note): void - { - $note->loadAdditionalFields(); - } - /** * Find a record stream records. * @@ -227,7 +77,7 @@ class RecordService throw new Forbidden(); } - $builder = $this->buildBaseQueryBuilder($searchParams); + $builder = $this->helper->buildBaseQueryBuilder($searchParams); $where = $this->user->isPortal() ? [ @@ -287,7 +137,7 @@ class RecordService $e->loadParentNameField('related'); } - $this->applyAccessControlToNote($e); + $this->noteAccessControl->apply($e, $this->user); } $count = $this->entityManager @@ -298,208 +148,6 @@ class RecordService return RecordCollection::create($collection, $count); } - /** - * @throws BadRequest - * @throws Forbidden - */ - private function buildBaseQueryBuilder(SearchParams $searchParams): SelectQueryBuilder - { - $builder = $this->entityManager - ->getQueryBuilder() - ->select() - ->from(Note::ENTITY_TYPE); - - if ( - $searchParams->getWhere() || - $searchParams->getTextFilter() || - $searchParams->getPrimaryFilter() || - $searchParams->getBoolFilterList() !== [] - ) { - $builder = $this->selectBuilderFactory - ->create() - ->from(Note::ENTITY_TYPE) - ->withComplexExpressionsForbidden() - ->withWherePermissionCheck() - ->withSearchParams( - $searchParams - ->withOffset(null) - ->withMaxSize(null) - ) - ->buildQueryBuilder() - ->order([]); - } - - return $builder; - } - - /** - * @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 (Acl\Exceptions\NotAvailable) { - 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, bool $forParent = false): 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) && - (!$forParent || $aclManager->checkScope($user, $scope, Table::ACTION_STREAM)); - } - catch (AclNotImplemented) { - $hasAccess = false; - } - - if (!$hasAccess) { - $ignoreScopeList[] = $scope; - } - } - - return $ignoreScopeList; - } - - private function applyAccessControlToNote(Note $note, ?User $user = null): void - { - if (!$user) { - $user = $this->user; - } - - $this->noteAccessControl->apply($note, $user); - } - /** * @param array $where */ @@ -514,8 +162,8 @@ class RecordService return; } - $onlyTeamEntityTypeList = $this->getOnlyTeamEntityTypeList($this->user); - $onlyOwnEntityTypeList = $this->getOnlyOwnEntityTypeList($this->user); + $onlyTeamEntityTypeList = $this->helper->getOnlyTeamEntityTypeList($this->user); + $onlyOwnEntityTypeList = $this->helper->getOnlyOwnEntityTypeList($this->user); if ( !count($onlyTeamEntityTypeList) && @@ -595,8 +243,8 @@ class RecordService */ private function applyIgnore(array &$where): void { - $ignoreScopeList = $this->getIgnoreScopeList($this->user, true); - $ignoreRelatedScopeList = $this->getIgnoreScopeList($this->user); + $ignoreScopeList = $this->helper->getIgnoreScopeList($this->user, true); + $ignoreRelatedScopeList = $this->helper->getIgnoreScopeList($this->user); if ($ignoreRelatedScopeList === []) { return; @@ -637,7 +285,7 @@ class RecordService return; } - $notAllEntityTypeList = $this->getNotAllEntityTypeList($this->user); + $notAllEntityTypeList = $this->helper->getNotAllEntityTypeList($this->user); $orGroup = [ [ @@ -671,448 +319,4 @@ class RecordService 'OR' => $orGroup, ]; } - - /** - * @param string[] $onlyTeamEntityTypeList - * @param string[] $onlyOwnEntityTypeList - * @param Select[] $queryList - */ - private function buildSubscriptionQueriesInternal( - User $user, - SelectQueryBuilder $builder, - array &$queryList, - array $onlyTeamEntityTypeList, - array $onlyOwnEntityTypeList - ): void { - - if ($user->isPortal()) { - return; - } - - $resetBuilder = clone $builder; - - $resetBuilder->where([ - 'OR' => [ - [ - 'relatedId!=' => null, - 'relatedType!=' => array_merge( - $onlyTeamEntityTypeList, - $onlyOwnEntityTypeList, - ), - ], - [ - 'relatedId=' => null, - ], - ], - ]); - - $queryList[] = $resetBuilder->build(); - - if (count($onlyTeamEntityTypeList)) { - $teamBuilder = clone $builder; - - $teamBuilder - ->where([ - 'relatedId!=' => null, - 'relatedType=' => $onlyTeamEntityTypeList, - 'id=s' => SelectQueryBuilder::create() - ->select('id') - ->from(Note::ENTITY_TYPE) - ->leftJoin('NoteTeam', 'noteTeam', [ - 'noteTeam.noteId=:' => 'id', - 'noteTeam.deleted' => false, - ]) - ->leftJoin('NoteUser', 'noteUser', [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - ]) - ->where([ - 'OR' => [ - 'noteTeam.teamId' => $user->getTeamIdList(), - 'noteUser.userId' => $user->getId(), - ] - ]) - ->build() - ->getRaw(), - ]); - - $queryList[] = $teamBuilder->build(); - } - - if (count($onlyOwnEntityTypeList)) { - $ownBuilder = clone $builder; - - $ownBuilder - ->where([ - 'relatedId!=' => null, - 'relatedType=' => $onlyOwnEntityTypeList, - 'id=s' => SelectQueryBuilder::create() - ->select('id') - ->from(Note::ENTITY_TYPE) - ->leftJoin('NoteUser', 'noteUser', [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - ]) - ->where(['noteUser.userId' => $user->getId()]) - ->build() - ->getRaw(), - ]); - - $queryList[] = $ownBuilder->build(); - } - } - - /** - * @param Select[] $queryList - */ - private function buildSubscriptionQueriesPortal( - User $user, - SelectQueryBuilder $builder, - array &$queryList - ): void { - - if (!$user->isPortal()) { - return; - } - - $builder->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(), - ]; - - $builder->leftJoin( - 'noteUser', - 'noteUser', [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - 'note.relatedType' => Email::ENTITY_TYPE, - ] - ); - } - - $builder->where([ - 'OR' => $orGroup, - ]); - - $queryList[] = $builder->build(); - } - - /** - * @param Select[] $queryList - */ - private function buildSubscriptionQueries( - User $user, - SelectQueryBuilder $baseBuilder, - array &$queryList - ): void { - - $onlyTeamEntityTypeList = $this->getOnlyTeamEntityTypeList($user); - $onlyOwnEntityTypeList = $this->getOnlyOwnEntityTypeList($user); - $ignoreWhereClause = $this->getSubscriptionIgnoreWhereClause($user); - - $builder = clone $baseBuilder; - - $builder - ->leftJoin('createdBy') - ->join( - Subscription::ENTITY_TYPE, - 'subscription', - [ - 'entityType:' => 'parentType', - 'entityId:' => 'parentId', - 'subscription.userId' => $user->getId(), - ] - ) - ->where($ignoreWhereClause); - - if ($user->isPortal()) { - $this->buildSubscriptionQueriesPortal($user, $builder, $queryList); - - return; - } - - $this->buildSubscriptionQueriesInternal( - $user, - $builder, - $queryList, - $onlyTeamEntityTypeList, - $onlyOwnEntityTypeList - ); - } - - /** - * @param Select[] $queryList - */ - private function buildSubscriptionSuperQuery( - User $user, - SelectQueryBuilder $baseBuilder, - array &$queryList - ): void { - - if ($user->isPortal()) { - return; - } - - $onlyTeamEntityTypeList = $this->getOnlyTeamEntityTypeList($user); - $onlyOwnEntityTypeList = $this->getOnlyOwnEntityTypeList($user); - $ignoreWhereClause = $this->getSubscriptionIgnoreWhereClause($user); - - $builder = clone $baseBuilder; - - $builder - ->join( - Subscription::ENTITY_TYPE, - 'subscription', - [ - 'entityType:' => 'superParentType', - 'entityId:' => 'superParentId', - 'subscription.userId' => $user->getId(), - ] - ) - ->leftJoin( - Subscription::ENTITY_TYPE, - 'subscriptionExclude', - [ - 'entityType:' => 'parentType', - 'entityId:' => 'parentId', - 'subscription.userId' => $user->getId(), - ] - ) - ->where([ - 'OR' => [ - 'parentId!=:' => 'superParentId', - 'parentType!=:' => 'superParentType', - ], - 'subscriptionExclude.id' => null, - ]) - ->where($ignoreWhereClause); - - $resetBuilder = clone $builder; - - $resetBuilder->where([ - 'OR' => [ - [ - 'relatedId!=' => null, - 'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), - ], - [ - 'relatedId=' => null, - 'parentType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), - ], - ], - ]); - - $queryList[] = $resetBuilder->build(); - - if (count($onlyTeamEntityTypeList)) { - $teamBuilder = clone $builder; - - $teamBuilder - ->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' => $user->getTeamIdList(), - 'noteUser.userId' => $user->getId(), - ], - ] - ]); - - $queryList[] = $teamBuilder->build(); - } - - if (count($onlyOwnEntityTypeList)) { - $ownBuilder = clone $builder; - - $ownBuilder - ->distinct() - ->leftJoin( - 'noteUser', - 'noteUser', - [ - 'noteUser.noteId=:' => 'id', - 'noteUser.deleted' => false, - ] - ) - ->where([ - [ - 'relatedId!=' => null, - 'relatedType=' => $onlyOwnEntityTypeList, - ], - 'noteUser.userId' => $user->getId(), - ]); - - $queryList[] = $ownBuilder->build(); - } - } - - /** - * @param Select[] $queryList - */ - private function buildPostedToUserQuery( - User $user, - SelectQueryBuilder $baseBuilder, - array &$queryList - ): void { - - $queryList[] = (clone $baseBuilder) - ->leftJoin('users') - ->leftJoin('createdBy') - ->where([ - 'createdById!=' => $user->getId(), - 'usersMiddle.userId' => $user->getId(), - 'parentId' => null, - 'type' => Note::TYPE_POST, - 'isGlobal' => false, - ]) - ->build(); - } - - /** - * @param Select[] $queryList - */ - private function buildPostedToPortalQuery( - User $user, - SelectQueryBuilder $baseBuilder, - array &$queryList - ): void { - - if (!$user->isPortal()) { - return; - } - - $portalIdList = $user->getLinkMultipleIdList('portals'); - - if ($portalIdList === []) { - return; - } - - $queryList[] = (clone $baseBuilder) - ->leftJoin('portals') - ->leftJoin('createdBy') - ->where([ - 'parentId' => null, - 'portalsMiddle.portalId' => $portalIdList, - 'type' => Note::TYPE_POST, - 'isGlobal' => false, - ]) - ->build(); - } - - /** - * @param Select[] $queryList - */ - private function buildPostedToTeamsQuery( - User $user, - SelectQueryBuilder $baseBuilder, - array &$queryList - ): void { - - if ($user->getTeamIdList() === []) { - return; - } - - $queryList[] = (clone $baseBuilder) - ->leftJoin('teams') - ->leftJoin('createdBy') - ->where([ - 'parentId' => null, - 'teamsMiddle.teamId' => $user->getTeamIdList(), - 'type' => Note::TYPE_POST, - 'isGlobal' => false, - ]) - ->build(); - } - - /** - * @param Select[] $queryList - */ - private function buildPostedByUserQuery( - User $user, - SelectQueryBuilder $baseBuilder, - array &$queryList - ): void { - - $queryList[] = (clone $baseBuilder) - ->leftJoin('createdBy') - ->where([ - 'createdById' => $user->getId(), - 'parentId' => null, - 'type' => Note::TYPE_POST, - 'isGlobal' => false, - ]) - ->build(); - } - - /** - * @param Select[] $queryList - */ - private function buildPostedToGlobalQuery( - User $user, - SelectQueryBuilder $baseBuilder, - array &$queryList - ): void { - - if ( - $user->isPortal() && - !$user->isAdmin() || $user->isApi() - ) { - return; - } - - $queryList[] = (clone $baseBuilder) - ->leftJoin('createdBy') - ->where([ - 'parentId' => null, - 'type' => Note::TYPE_POST, - 'isGlobal' => true, - ]) - ->build(); - } } diff --git a/application/Espo/Tools/Stream/RecordService/Helper.php b/application/Espo/Tools/Stream/RecordService/Helper.php new file mode 100644 index 0000000000..7184f25d7a --- /dev/null +++ b/application/Espo/Tools/Stream/RecordService/Helper.php @@ -0,0 +1,250 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Tools\Stream\RecordService; + +use Espo\Core\Acl\Exceptions\NotAvailable; +use Espo\Core\Acl\Exceptions\NotImplemented as AclNotImplemented; +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Note; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\SelectBuilder as SelectQueryBuilder; + +class Helper +{ + public function __construct( + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private Metadata $metadata, + private AclManager $aclManager, + private UserAclManagerProvider $userAclManagerProvider + ) {} + + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function buildBaseQueryBuilder(SearchParams $searchParams): SelectQueryBuilder + { + $builder = $this->entityManager + ->getQueryBuilder() + ->select() + ->from(Note::ENTITY_TYPE); + + if ( + $searchParams->getWhere() || + $searchParams->getTextFilter() || + $searchParams->getPrimaryFilter() || + $searchParams->getBoolFilterList() !== [] + ) { + $builder = $this->selectBuilderFactory + ->create() + ->from(Note::ENTITY_TYPE) + ->withComplexExpressionsForbidden() + ->withWherePermissionCheck() + ->withSearchParams( + $searchParams + ->withOffset(null) + ->withMaxSize(null) + ) + ->buildQueryBuilder() + ->order([]); + } + + return $builder; + } + + /** + * @return string[] + */ + public 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[] + */ + public 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; + } + + /** + * @return string[] + */ + public function getIgnoreScopeList(User $user, bool $forParent = false): 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) && + (!$forParent || $aclManager->checkScope($user, $scope, Table::ACTION_STREAM)); + } + catch (AclNotImplemented) { + $hasAccess = false; + } + + if (!$hasAccess) { + $ignoreScopeList[] = $scope; + } + } + + return $ignoreScopeList; + } + + private function getUserAclManager(User $user): ?AclManager + { + try { + return $this->userAclManagerProvider->get($user); + } + catch (NotAvailable) { + return null; + } + } + + /** + * @return string[] + */ + public 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; + } +} diff --git a/application/Espo/Tools/Stream/UserRecordService.php b/application/Espo/Tools/Stream/UserRecordService.php new file mode 100644 index 0000000000..ef1069dc0f --- /dev/null +++ b/application/Espo/Tools/Stream/UserRecordService.php @@ -0,0 +1,692 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Tools\Stream; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Select\SearchParams; +use Espo\ORM\EntityManager; +use Espo\Entities\Subscription; +use Espo\Entities\User; +use Espo\Entities\Note; +use Espo\Entities\Email; +use Espo\Core\Utils\Metadata; +use Espo\Core\Acl; +use Espo\Core\AclManager; +use Espo\Core\Acl\Table; +use Espo\Core\Record\Collection as RecordCollection; +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use Espo\ORM\Query\Part\Order; +use Espo\ORM\Query\Select; +use Espo\ORM\Query\SelectBuilder as SelectQueryBuilder; +use Espo\Tools\Stream\RecordService\Helper; + +class UserRecordService +{ + public function __construct( + private EntityManager $entityManager, + private User $user, + private Metadata $metadata, + private Acl $acl, + private UserAclManagerProvider $userAclManagerProvider, + private NoteAccessControl $noteAccessControl, + private Helper $helper + ) {} + + /** + * Find user stream records. + * + * @return RecordCollection + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + */ + public function find(?string $userId, SearchParams $searchParams): RecordCollection + { + $userId ??= $this->user->getId(); + + $offset = $searchParams->getOffset() ?? 0; + $maxSize = $searchParams->getMaxSize(); + + $sqLimit = $offset + $maxSize + 1; + + $user = $userId === $this->user->getId() ? + $this->user : + $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + throw new NotFound("User not found."); + } + + /** @noinspection PhpRedundantOptionalArgumentInspection */ + if (!$this->acl->checkUserPermission($user, 'user')) { + throw new Forbidden("No user permission access."); + } + + $queryList = []; + + $baseBuilder = $this->helper->buildBaseQueryBuilder($searchParams) + ->select([ + 'id', + 'number', + 'type', + 'post', + 'data', + 'parentType', + 'parentId', + 'relatedType', + 'relatedId', + 'targetType', + 'createdAt', + 'createdById', + 'createdByName', + 'isGlobal', + 'isInternal', + 'createdByGender', + ]) + ->order('number', Order::DESC) + ->limit(0, $sqLimit); + + $this->buildSubscriptionQueries($user, $baseBuilder, $queryList); + $this->buildSubscriptionSuperQuery($user, $baseBuilder, $queryList); + $this->buildPostedToUserQuery($user, $baseBuilder, $queryList); + $this->buildPostedToPortalQuery($user, $baseBuilder, $queryList); + $this->buildPostedToTeamsQuery($user, $baseBuilder, $queryList); + $this->buildPostedByUserQuery($user, $baseBuilder, $queryList); + $this->buildPostedToGlobalQuery($user, $baseBuilder, $queryList); + + $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 + ->getRDBRepositoryByClass(Note::class) + ->findBySql($sql); + + $collection = $this->entityManager + ->getCollectionFactory() + ->createFromSthCollection($sthCollection); + + foreach ($collection as $e) { + $this->loadNoteAdditionalFields($e); + $this->noteAccessControl->apply($e, $user); + } + + return RecordCollection::createNoCount($collection, $maxSize); + } + + /** + * @return array + */ + private function getSubscriptionIgnoreWhereClause(User $user): array + { + $ignoreScopeList = $this->helper->getIgnoreScopeList($user, true); + $ignoreRelatedScopeList = $this->helper->getIgnoreScopeList($user); + + if (empty($ignoreScopeList)) { + return []; + } + + $whereClause = []; + + $whereClause[] = [ + 'OR' => [ + 'relatedType' => null, + 'relatedType!=' => $ignoreRelatedScopeList, + ] + ]; + + $whereClause[] = [ + 'OR' => [ + 'parentType' => null, + 'parentType!=' => $ignoreScopeList, + ] + ]; + + if (in_array(Email::ENTITY_TYPE, $ignoreRelatedScopeList)) { + $whereClause[] = [ + 'type!=' => [ + Note::TYPE_EMAIL_RECEIVED, + Note::TYPE_EMAIL_SENT, + ], + ]; + } + + return $whereClause; + } + + private function loadNoteAdditionalFields(Note $note): void + { + $note->loadAdditionalFields(); + } + + private function getUserAclManager(User $user): ?AclManager + { + try { + return $this->userAclManagerProvider->get($user); + } + catch (Acl\Exceptions\NotAvailable) { + 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']) || empty($item['object'])) { + continue; + } + + if ( + !$aclManager || + $aclManager->getLevel($user, $scope, Table::ACTION_READ) !== Table::LEVEL_ALL + ) { + $list[] = $scope; + } + } + + return $list; + } + + /** + * @param string[] $onlyTeamEntityTypeList + * @param string[] $onlyOwnEntityTypeList + * @param Select[] $queryList + */ + private function buildSubscriptionQueriesInternal( + User $user, + SelectQueryBuilder $builder, + array &$queryList, + array $onlyTeamEntityTypeList, + array $onlyOwnEntityTypeList + ): void { + + if ($user->isPortal()) { + return; + } + + $resetBuilder = clone $builder; + + $resetBuilder->where([ + 'OR' => [ + [ + 'relatedId!=' => null, + 'relatedType!=' => array_merge( + $onlyTeamEntityTypeList, + $onlyOwnEntityTypeList, + ), + ], + [ + 'relatedId=' => null, + ], + ], + ]); + + $queryList[] = $resetBuilder->build(); + + if (count($onlyTeamEntityTypeList)) { + $teamBuilder = clone $builder; + + $teamBuilder + ->where([ + 'relatedId!=' => null, + 'relatedType=' => $onlyTeamEntityTypeList, + 'id=s' => SelectQueryBuilder::create() + ->select('id') + ->from(Note::ENTITY_TYPE) + ->leftJoin('NoteTeam', 'noteTeam', [ + 'noteTeam.noteId=:' => 'id', + 'noteTeam.deleted' => false, + ]) + ->leftJoin('NoteUser', 'noteUser', [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + ]) + ->where([ + 'OR' => [ + 'noteTeam.teamId' => $user->getTeamIdList(), + 'noteUser.userId' => $user->getId(), + ] + ]) + ->build() + ->getRaw(), + ]); + + $queryList[] = $teamBuilder->build(); + } + + if (count($onlyOwnEntityTypeList)) { + $ownBuilder = clone $builder; + + $ownBuilder + ->where([ + 'relatedId!=' => null, + 'relatedType=' => $onlyOwnEntityTypeList, + 'id=s' => SelectQueryBuilder::create() + ->select('id') + ->from(Note::ENTITY_TYPE) + ->leftJoin('NoteUser', 'noteUser', [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + ]) + ->where(['noteUser.userId' => $user->getId()]) + ->build() + ->getRaw(), + ]); + + $queryList[] = $ownBuilder->build(); + } + } + + /** + * @param Select[] $queryList + */ + private function buildSubscriptionQueriesPortal( + User $user, + SelectQueryBuilder $builder, + array &$queryList + ): void { + + if (!$user->isPortal()) { + return; + } + + $builder->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(), + ]; + + $builder->leftJoin( + 'noteUser', + 'noteUser', [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + 'note.relatedType' => Email::ENTITY_TYPE, + ] + ); + } + + $builder->where([ + 'OR' => $orGroup, + ]); + + $queryList[] = $builder->build(); + } + + /** + * @param Select[] $queryList + */ + private function buildSubscriptionQueries( + User $user, + SelectQueryBuilder $baseBuilder, + array &$queryList + ): void { + + $onlyTeamEntityTypeList = $this->helper->getOnlyTeamEntityTypeList($user); + $onlyOwnEntityTypeList = $this->helper->getOnlyOwnEntityTypeList($user); + $ignoreWhereClause = $this->getSubscriptionIgnoreWhereClause($user); + + $builder = clone $baseBuilder; + + $builder + ->leftJoin('createdBy') + ->join( + Subscription::ENTITY_TYPE, + 'subscription', + [ + 'entityType:' => 'parentType', + 'entityId:' => 'parentId', + 'subscription.userId' => $user->getId(), + ] + ) + ->where($ignoreWhereClause); + + if ($user->isPortal()) { + $this->buildSubscriptionQueriesPortal($user, $builder, $queryList); + + return; + } + + $this->buildSubscriptionQueriesInternal( + $user, + $builder, + $queryList, + $onlyTeamEntityTypeList, + $onlyOwnEntityTypeList + ); + } + + /** + * @param Select[] $queryList + */ + private function buildSubscriptionSuperQuery( + User $user, + SelectQueryBuilder $baseBuilder, + array &$queryList + ): void { + + if ($user->isPortal()) { + return; + } + + $onlyTeamEntityTypeList = $this->helper->getOnlyTeamEntityTypeList($user); + $onlyOwnEntityTypeList = $this->helper->getOnlyOwnEntityTypeList($user); + $ignoreWhereClause = $this->getSubscriptionIgnoreWhereClause($user); + + $builder = clone $baseBuilder; + + $builder + ->join( + Subscription::ENTITY_TYPE, + 'subscription', + [ + 'entityType:' => 'superParentType', + 'entityId:' => 'superParentId', + 'subscription.userId' => $user->getId(), + ] + ) + ->leftJoin( + Subscription::ENTITY_TYPE, + 'subscriptionExclude', + [ + 'entityType:' => 'parentType', + 'entityId:' => 'parentId', + 'subscription.userId' => $user->getId(), + ] + ) + ->where([ + 'OR' => [ + 'parentId!=:' => 'superParentId', + 'parentType!=:' => 'superParentType', + ], + 'subscriptionExclude.id' => null, + ]) + ->where($ignoreWhereClause); + + $resetBuilder = clone $builder; + + $resetBuilder->where([ + 'OR' => [ + [ + 'relatedId!=' => null, + 'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), + ], + [ + 'relatedId=' => null, + 'parentType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList), + ], + ], + ]); + + $queryList[] = $resetBuilder->build(); + + if (count($onlyTeamEntityTypeList)) { + $teamBuilder = clone $builder; + + $teamBuilder + ->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' => $user->getTeamIdList(), + 'noteUser.userId' => $user->getId(), + ], + ] + ]); + + $queryList[] = $teamBuilder->build(); + } + + if (count($onlyOwnEntityTypeList)) { + $ownBuilder = clone $builder; + + $ownBuilder + ->distinct() + ->leftJoin( + 'noteUser', + 'noteUser', + [ + 'noteUser.noteId=:' => 'id', + 'noteUser.deleted' => false, + ] + ) + ->where([ + [ + 'relatedId!=' => null, + 'relatedType=' => $onlyOwnEntityTypeList, + ], + 'noteUser.userId' => $user->getId(), + ]); + + $queryList[] = $ownBuilder->build(); + } + } + + /** + * @param Select[] $queryList + */ + private function buildPostedToUserQuery( + User $user, + SelectQueryBuilder $baseBuilder, + array &$queryList + ): void { + + $queryList[] = (clone $baseBuilder) + ->leftJoin('users') + ->leftJoin('createdBy') + ->where([ + 'createdById!=' => $user->getId(), + 'usersMiddle.userId' => $user->getId(), + 'parentId' => null, + 'type' => Note::TYPE_POST, + 'isGlobal' => false, + ]) + ->build(); + } + + /** + * @param Select[] $queryList + */ + private function buildPostedToPortalQuery( + User $user, + SelectQueryBuilder $baseBuilder, + array &$queryList + ): void { + + if (!$user->isPortal()) { + return; + } + + $portalIdList = $user->getLinkMultipleIdList('portals'); + + if ($portalIdList === []) { + return; + } + + $queryList[] = (clone $baseBuilder) + ->leftJoin('portals') + ->leftJoin('createdBy') + ->where([ + 'parentId' => null, + 'portalsMiddle.portalId' => $portalIdList, + 'type' => Note::TYPE_POST, + 'isGlobal' => false, + ]) + ->build(); + } + + /** + * @param Select[] $queryList + */ + private function buildPostedToTeamsQuery( + User $user, + SelectQueryBuilder $baseBuilder, + array &$queryList + ): void { + + if ($user->getTeamIdList() === []) { + return; + } + + $queryList[] = (clone $baseBuilder) + ->leftJoin('teams') + ->leftJoin('createdBy') + ->where([ + 'parentId' => null, + 'teamsMiddle.teamId' => $user->getTeamIdList(), + 'type' => Note::TYPE_POST, + 'isGlobal' => false, + ]) + ->build(); + } + + /** + * @param Select[] $queryList + */ + private function buildPostedByUserQuery( + User $user, + SelectQueryBuilder $baseBuilder, + array &$queryList + ): void { + + $queryList[] = (clone $baseBuilder) + ->leftJoin('createdBy') + ->where([ + 'createdById' => $user->getId(), + 'parentId' => null, + 'type' => Note::TYPE_POST, + 'isGlobal' => false, + ]) + ->build(); + } + + /** + * @param Select[] $queryList + */ + private function buildPostedToGlobalQuery( + User $user, + SelectQueryBuilder $baseBuilder, + array &$queryList + ): void { + + if ( + $user->isPortal() && + !$user->isAdmin() || $user->isApi() + ) { + return; + } + + $queryList[] = (clone $baseBuilder) + ->leftJoin('createdBy') + ->where([ + 'parentId' => null, + 'type' => Note::TYPE_POST, + 'isGlobal' => true, + ]) + ->build(); + } +}